diff --git a/.env.example b/.env.example index d1e4792..09c9db6 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,26 @@ # libnovel scraper — environment overrides # Copy to .env and adjust values; do NOT commit this file with real secrets. +# ── Service ports (host-side) ───────────────────────────────────────────────── +# Port the scraper HTTP API listens on (default 8080) +SCRAPER_PORT=8080 + +# Port PocketBase listens on (default 8090) +POCKETBASE_PORT=8090 + +# Port MinIO S3 API listens on (default 9000) +MINIO_PORT=9000 + +# Port MinIO web console listens on (default 9001) +MINIO_CONSOLE_PORT=9001 + +# Port Browserless Chrome listens on (default 3030) +BROWSERLESS_PORT=3030 + +# Port the SvelteKit UI listens on (default 3000) +UI_PORT=3000 + +# ── Browserless ─────────────────────────────────────────────────────────────── # Browserless API token (leave empty to disable auth) BROWSERLESS_TOKEN= @@ -19,10 +39,7 @@ ERROR_ALERT_URL= # Which Browserless strategy the scraper uses: content | scrape | cdp | direct BROWSERLESS_STRATEGY=direct -# Strategy for URL retrieval (chapter list). Uses browserless content strategy by default. -# Set to direct to use plain HTTP, or content/scrape/cdp for browserless. -BROWSERLESS_URL_STRATEGY=content - +# ── Scraper ─────────────────────────────────────────────────────────────────── # Chapter worker goroutines (0 = NumCPU inside the container) SCRAPER_WORKERS=0 @@ -39,3 +56,28 @@ KOKORO_URL=http://kokoro:8880 # Single voices: af_bella, af_sky, af_heart, am_adam, … # Mixed voices: af_bella+af_sky or af_bella(2)+af_sky(1) (weighted blend) KOKORO_VOICE=af_bella + +# ── MinIO / S3 object storage ───────────────────────────────────────────────── +MINIO_ROOT_USER=admin +MINIO_ROOT_PASSWORD=changeme123 +MINIO_BUCKET_CHAPTERS=libnovel-chapters +MINIO_BUCKET_AUDIO=libnovel-audio + +# ── PocketBase ──────────────────────────────────────────────────────────────── +# Admin credentials (used by scraper + UI server-side) +POCKETBASE_ADMIN_EMAIL=admin@libnovel.local +POCKETBASE_ADMIN_PASSWORD=changeme123 + +# ── SvelteKit UI ───────────────────────────────────────────────────────────── +# Internal URL the SvelteKit server uses to reach the scraper API. +# In docker-compose this is http://scraper:8080 (wired automatically). +# Override here only if running the UI outside of docker-compose. +SCRAPER_API_URL=http://localhost:8080 + +# Internal URL the SvelteKit server uses to reach PocketBase. +# In docker-compose this is http://pocketbase:8090 (wired automatically). +POCKETBASE_URL=http://localhost:8090 + +# Public MinIO URL reachable from the browser (for audio/presigned URLs). +# In production, point this at your MinIO reverse-proxy or CDN domain. +PUBLIC_MINIO_PUBLIC_URL=http://localhost:9000 diff --git a/.gitea/workflows/ci-scraper.yaml b/.gitea/workflows/ci-scraper.yaml new file mode 100644 index 0000000..c64a042 --- /dev/null +++ b/.gitea/workflows/ci-scraper.yaml @@ -0,0 +1,79 @@ +name: CI / Scraper + +on: + push: + branches: ["main", "master", "v2"] + paths: + - "scraper/**" + - ".gitea/workflows/ci-scraper.yaml" + pull_request: + branches: ["main", "master", "v2"] + paths: + - "scraper/**" + - ".gitea/workflows/ci-scraper.yaml" + +concurrency: + group: ${{ gitea.workflow }}-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + # ── lint & vet ─────────────────────────────────────────────────────────────── + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: scraper/go.mod + cache-dependency-path: scraper/go.sum + + - name: go vet + working-directory: scraper + run: | + go vet ./... + go vet -tags integration ./... + + # ── tests ──────────────────────────────────────────────────────────────────── + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: scraper/go.mod + cache-dependency-path: scraper/go.sum + + - name: Run tests + working-directory: scraper + run: go test -short -race -count=1 -timeout=60s ./... + + # ── push to Docker Hub ─────────────────────────────────────────────────────── + docker: + name: Docker Push + runs-on: ubuntu-latest + needs: [lint, test] + if: gitea.event_name == 'push' + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: scraper + push: true + tags: | + ${{ secrets.DOCKER_USER }}/libnovel-scraper:latest + ${{ secrets.DOCKER_USER }}/libnovel-scraper:${{ gitea.sha }} + build-args: | + VERSION=${{ gitea.sha }} + COMMIT=${{ gitea.sha }} diff --git a/.gitea/workflows/ci-ui.yaml b/.gitea/workflows/ci-ui.yaml new file mode 100644 index 0000000..3339885 --- /dev/null +++ b/.gitea/workflows/ci-ui.yaml @@ -0,0 +1,70 @@ +name: CI / UI + +on: + push: + branches: ["main", "master", "v2"] + paths: + - "ui/**" + - ".gitea/workflows/ci-ui.yaml" + pull_request: + branches: ["main", "master", "v2"] + paths: + - "ui/**" + - ".gitea/workflows/ci-ui.yaml" + +concurrency: + group: ${{ gitea.workflow }}-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + # ── type-check & build ─────────────────────────────────────────────────────── + build: + name: Build + runs-on: ubuntu-latest + defaults: + run: + working-directory: ui + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: ui/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run check + + - name: Build + run: npm run build + + # ── push to Docker Hub ─────────────────────────────────────────────────────── + docker: + name: Docker Push + runs-on: ubuntu-latest + needs: build + if: gitea.event_name == 'push' + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ui + push: true + tags: | + ${{ secrets.DOCKER_USER }}/libnovel-ui:latest + ${{ secrets.DOCKER_USER }}/libnovel-ui:${{ gitea.sha }} + build-args: | + BUILD_VERSION=${{ gitea.sha }} + BUILD_COMMIT=${{ gitea.sha }} diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml deleted file mode 100644 index 621fdc5..0000000 --- a/.gitea/workflows/ci.yaml +++ /dev/null @@ -1,106 +0,0 @@ -name: CI - -on: - push: - branches: ["main", "master"] - paths: - - "scraper/**" - - ".gitea/workflows/**" - pull_request: - branches: ["main", "master"] - paths: - - "scraper/**" - - ".gitea/workflows/**" - -defaults: - run: - working-directory: scraper - -jobs: - # ── lint & vet ─────────────────────────────────────────────────────────────── - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: scraper/go.mod - cache-dependency-path: scraper/go.sum - - - name: go vet - run: go vet ./... - - - name: staticcheck - run: | - go install honnef.co/go/tools/cmd/staticcheck@latest - staticcheck ./... - - # ── tests ──────────────────────────────────────────────────────────────────── - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: scraper/go.mod - cache-dependency-path: scraper/go.sum - - - name: Run tests - run: go test -race -count=1 -timeout=60s ./... - - # ── build binary ───────────────────────────────────────────────────────────── - build: - name: Build - runs-on: ubuntu-latest - needs: [lint, test] - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: scraper/go.mod - cache-dependency-path: scraper/go.sum - - - name: Build binary - run: | - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ - go build -ldflags="-s -w" -o bin/scraper ./cmd/scraper - - - name: Upload binary artifact - uses: actions/upload-artifact@v4 - with: - name: scraper-linux-amd64 - path: scraper/bin/scraper - retention-days: 7 - - # ── docker build (& push) ──────────────────────────────────────────────────── - # Uncomment once the runner has Docker available and a registry is configured. - # - # docker: - # name: Docker - # runs-on: ubuntu-latest - # needs: [lint, test] - # # Only push images on commits to the default branch, not on PRs. - # # if: github.event_name == 'push' - # steps: - # - uses: actions/checkout@v4 - # - # - name: Log in to Gitea registry - # uses: docker/login-action@v3 - # with: - # registry: gitea.kalekber.cc - # username: ${{ secrets.REGISTRY_USER }} - # password: ${{ secrets.REGISTRY_TOKEN }} - # - # - name: Build and push - # uses: docker/build-push-action@v5 - # with: - # context: ./scraper - # push: true - # tags: | - # gitea.kalekber.cc/kamil/libnovel:latest - # gitea.kalekber.cc/kamil/libnovel:${{ gitea.sha }} diff --git a/.gitea/workflows/ios.yaml b/.gitea/workflows/ios.yaml new file mode 100644 index 0000000..6d2d21d --- /dev/null +++ b/.gitea/workflows/ios.yaml @@ -0,0 +1,63 @@ +name: iOS CI + +on: + push: + branches: ["v2", "main"] + paths: + - "ios/**" + - "justfile" + - ".gitea/workflows/ios.yaml" + - ".gitea/workflows/ios-release.yaml" + pull_request: + branches: ["v2", "main"] + paths: + - "ios/**" + - "justfile" + - ".gitea/workflows/ios.yaml" + - ".gitea/workflows/ios-release.yaml" + +concurrency: + group: ios-macos-runner + cancel-in-progress: true + +jobs: + # ── build (simulator) ───────────────────────────────────────────────────── + build: + name: Build + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install just + run: command -v just || brew install just + + - name: Build (simulator) + env: + USER: runner + run: just ios-build + + # ── unit tests ──────────────────────────────────────────────────────────── + test: + name: Test + runs-on: macos-latest + needs: build + + steps: + - uses: actions/checkout@v4 + + - name: Install just + run: command -v just || brew install just + + - name: Run unit tests + env: + USER: runner + run: just ios-test + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: ios/LibNovel/test-results.xml + retention-days: 7 diff --git a/.gitea/workflows/release-scraper.yaml b/.gitea/workflows/release-scraper.yaml new file mode 100644 index 0000000..666914d --- /dev/null +++ b/.gitea/workflows/release-scraper.yaml @@ -0,0 +1,68 @@ +name: Release / Scraper + +on: + push: + tags: + - "v*" + +concurrency: + group: ${{ gitea.workflow }}-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + # ── lint & test ────────────────────────────────────────────────────────────── + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: scraper/go.mod + cache-dependency-path: scraper/go.sum + + - name: go vet + working-directory: scraper + run: | + go vet ./... + go vet -tags integration ./... + + - name: Run tests + working-directory: scraper + run: go test -short -race -count=1 -timeout=60s ./... + + # ── docker build & push ────────────────────────────────────────────────────── + docker: + name: Docker + runs-on: ubuntu-latest + needs: [test] + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_USER }}/libnovel-scraper + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: scraper + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.meta.outputs.version }} + COMMIT=${{ gitea.sha }} diff --git a/.gitea/workflows/release-ui.yaml b/.gitea/workflows/release-ui.yaml new file mode 100644 index 0000000..4e535d0 --- /dev/null +++ b/.gitea/workflows/release-ui.yaml @@ -0,0 +1,71 @@ +name: Release / UI + +on: + push: + tags: + - "v*" + +concurrency: + group: ${{ gitea.workflow }}-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + # ── type-check & build ─────────────────────────────────────────────────────── + build: + name: Build + runs-on: ubuntu-latest + defaults: + run: + working-directory: ui + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: ui/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run check + + - name: Build + run: npm run build + + # ── docker build & push ────────────────────────────────────────────────────── + docker: + name: Docker + runs-on: ubuntu-latest + needs: [build] + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_USER }}/libnovel-ui + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ui + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + BUILD_VERSION=${{ steps.meta.outputs.version }} + BUILD_COMMIT=${{ gitea.sha }} diff --git a/.gitignore b/.gitignore index 0e45f0f..02102a6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # ── Compiled binaries ────────────────────────────────────────────────────────── scraper/bin/ +scraper/scraper # ── Scraped output (large, machine-generated) ────────────────────────────────── diff --git a/.opencode/skills/ios-ux/SKILL.md b/.opencode/skills/ios-ux/SKILL.md new file mode 100644 index 0000000..1d222ac --- /dev/null +++ b/.opencode/skills/ios-ux/SKILL.md @@ -0,0 +1,156 @@ +--- +name: ios-ux +description: iOS/SwiftUI UI & UX review and implementation guidelines for LibNovel. Enforces Apple HIG, iOS 17+ APIs, spring animations, haptics, accessibility, performance, and offline handling. Load this skill for any iOS view work. +compatibility: opencode +--- + +# iOS UI/UX Skill — LibNovel + +Load this skill whenever working on SwiftUI views in `ios/`. It defines design standards, review process for screenshots, and implementation rules. + +--- + +## Screenshot Review Process + +When the user provides a screenshot of the app: + +1. **Analyze first** — identify specific UI/UX issues across these categories: + - Visual hierarchy and spacing + - Typography (size, weight, contrast) + - Color and material usage + - Animation and interactivity gaps + - Accessibility problems + - Deprecated or non-native patterns +2. **Present a numbered list** of suggested improvements with brief rationale for each. +3. **Ask for confirmation** before writing any code: "Should I apply all of these, or only specific ones?" +4. Apply only what the user confirms. + +--- + +## Design System + +### Colors & Materials +- **Accent**: `Color.amber` (project-defined). Use for active state, selection indicators, progress fills, and CTAs. +- **Backgrounds**: Prefer `.regularMaterial`, `.ultraThinMaterial`, or `.thinMaterial` over hard-coded `Color.black.opacity(x)` or `Color(.systemBackground)`. +- **Dark overlays** (e.g. full-screen players): Use `KFImage` blurred background + `Color.black.opacity(0.5–0.6)` overlay. Never use a flat solid black background. +- **Semantic colors**: Use `.primary`, `.secondary`, `.tertiary` foreground styles. Avoid hard-coded `Color.white` except on dark material contexts (full-screen player). +- **No hardcoded color literals** — use `Color+App.swift` extensions or system semantic colors. + +### Typography +- Use the SF Pro system font via `.font(.title)`, `.font(.body)`, etc. — never hardcode font names except for intentional stylistic accents (e.g. "Snell Roundhand" for voice watermark). +- Apply `.fontWeight()` and `.fontDesign()` modifiers rather than custom font families. +- Support Dynamic Type — never hardcode a fixed font size as the sole option without a `.minimumScaleFactor` or system font size modifier. +- Hierarchy: title3.bold for primary labels, subheadline for secondary, caption/caption2 for metadata. + +### Spacing & Layout +- Minimum touch target: **44×44 pt**. Use `.frame(minWidth: 44, minHeight: 44)` or `.contentShape(Rectangle())` on small icons. +- Prefer 16–20 pt horizontal padding on full-width containers; 12 pt for compact inner elements. +- Use `VStack(spacing:)` and `HStack(spacing:)` explicitly — never rely on default spacing for production UI. +- Corner radii: 12–14 pt for cards/chips, 10 pt for small badges, 20–24 pt for large cover art. + +--- + +## Animation Rules + +### Spring Animations (default for all interactive transitions) +- Use `.spring(response:dampingFraction:)` for state-driven layout changes, selection feedback, and appear/disappear transitions. +- Recommended defaults: + - Interactive elements: `response: 0.3, dampingFraction: 0.7` + - Entrance animations: `response: 0.45–0.5, dampingFraction: 0.7` + - Quick snappy feedback: `response: 0.2, dampingFraction: 0.6` +- Reserve `.easeInOut` only for non-interactive, ambient animations (e.g. opacity pulses, generating overlays). + +### SF Symbol Transitions +- Always use `contentTransition(.symbolEffect(.replace.downUp))` when a symbol name changes based on state (play/pause, checkmark/circle, etc.). +- Use `.symbolEffect(.variableColor.cumulative)` for continuous animations (waveform, loading indicators). +- Use `.symbolEffect(.bounce)` for one-shot entrance emphasis (e.g. completion checkmark appearing). +- Use `.symbolEffect(.pulse)` for error/warning states that need attention. + +### Repeating Animations +- Use `phaseAnimator` for any looping animation that previously used manual `@State` + `withAnimation` chains. +- Do not use `Timer` publishers for UI animation — prefer `phaseAnimator` or `TimelineView`. + +--- + +## Haptic Feedback + +Add `UIImpactFeedbackGenerator` to every user-initiated interactive control: +- `.light` — toggle switches, selection chips, secondary actions, slider drag start. +- `.medium` — primary transport buttons (play/pause, chapter skip), significant confirmations. +- `.heavy` — destructive actions (only if no confirmation dialog). + +Pattern: +```swift +Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + // action +} label: { ... } +``` + +Do **not** add haptics to: +- Programmatic state changes not directly triggered by a tap. +- Buttons inside `List` rows that already use swipe actions. +- Scroll events. + +--- + +## iOS 17+ API Usage + +Flag and replace any of the following deprecated patterns: + +| Deprecated | Replace with | +|---|---| +| `NavigationView` | `NavigationStack` | +| `@StateObject` / `ObservableObject` (new types only) | `@Observable` macro | +| `DispatchQueue.main.async` | `await MainActor.run` or `@MainActor` | +| Manual `@State` animation chains for repeating loops | `phaseAnimator` | +| `.animation(_:)` without `value:` | `.animation(_:value:)` | +| `AnyView` wrapping for conditional content | `@ViewBuilder` + `Group` | + +Do **not** refactor existing `ObservableObject` types to `@Observable` unless explicitly asked — only apply `@Observable` to new types. + +--- + +## Accessibility + +Every view must: +- Support VoiceOver: add `.accessibilityLabel()` to icon-only buttons and image views. +- Support Dynamic Type: test that text doesn't truncate at xxxLarge without a layout adjustment. +- Meet contrast ratio: text on tinted backgrounds must be legible — avoid `.opacity(0.25)` or lower for any user-readable text. +- Touch targets ≥ 44pt (see Spacing above). +- Interactive controls must have `.accessibilityAddTraits(.isButton)` if not using `Button`. +- Do not rely solely on color to convey state — pair color with icon or label. + +--- + +## Performance + +- **Isolate high-frequency observers**: Any view that observes a `PlaybackProgress` (timer-tick updates) must be a separate sub-view that `@ObservedObject`-observes only the progress object — not the parent view. This prevents the entire parent from re-rendering every 0.5 seconds. +- **Avoid `id()` overuse**: Only use `.id()` to force view recreation when necessary (e.g. background image on track change). Prefer `onChange(of:)` for side effects. +- **Lazy containers**: Use `LazyVStack` / `LazyHStack` inside `ScrollView` for lists of 20+ items. `List` is inherently lazy and does not need this. +- **Image loading**: Always use `KFImage` (Kingfisher) with `.placeholder` for remote images. Never use `AsyncImage` for cover art — it has no disk cache. +- **Avoid `AnyView`**: It breaks structural identity and hurts diffing. Use `@ViewBuilder` or `Group { }` instead. + +--- + +## Offline & Error States + +Every view that makes network calls must: +1. Wrap the body in a `VStack` with `OfflineBanner` at the top, gated on `networkMonitor.isConnected`. +2. Suppress network errors silently when offline via `ErrorAlertModifier` — do not show an alert when the device is offline. +3. Gate `.task` / `.onAppear` network calls: `guard networkMonitor.isConnected else { return }`. +4. Show a non-blocking inline empty state (not a full-screen error) for failed loads when online. + +--- + +## Component Checklist (before submitting any view change) + +- [ ] All interactive elements ≥ 44pt touch target +- [ ] SF Symbol state changes use `contentTransition(.symbolEffect(...))` +- [ ] State-driven layout transitions use `.spring(response:dampingFraction:)` +- [ ] Tappable controls have haptic feedback +- [ ] No `NavigationView`, no `DispatchQueue.main.async`, no `.animation(_:)` without `value:` +- [ ] High-frequency observers are isolated sub-views +- [ ] Offline state handled with `OfflineBanner` + `NetworkMonitor` +- [ ] VoiceOver labels on icon-only buttons +- [ ] No hardcoded `Color.black` / `Color.white` / `Color(.systemBackground)` where a material applies diff --git a/AGENTS.md b/AGENTS.md index 23e4bae..55c814a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,28 +1,43 @@ # libnovel Project -Go web scraper for novelfire.net with TTS support via Kokoro-FastAPI. +Go web scraper for novelfire.net with TTS support via Kokoro-FastAPI. Structured data in PocketBase, binary blobs (chapters, audio, browse snapshots) in MinIO. SvelteKit frontend. ## Architecture ``` scraper/ -├── cmd/scraper/main.go # Entry point: 'run' (one-shot) and 'serve' (HTTP server) +├── cmd/scraper/main.go # Entry point: run | refresh | serve | save-browse ├── internal/ -│ ├── orchestrator/orchestrator.go # Coordinates catalogue walk, metadata extraction, chapter scraping -│ ├── browser/ # Browser client (content/scrape/cdp strategies) via Browserless -│ ├── novelfire/scraper.go # novelfire.net specific scraping logic -│ ├── server/server.go # HTTP API (POST /scrape, POST /scrape/book) -│ ├── writer/writer.go # File writer (metadata.yaml, chapter .md files) -│ └── scraper/interfaces.go # NovelScraper interface definition -└── static/books/ # Output directory for scraped content +│ ├── orchestrator/orchestrator.go # Catalogue walk → per-book metadata goroutines → chapter worker pool +│ ├── browser/ # BrowserClient interface + direct HTTP (production) + Browserless variants +│ ├── novelfire/scraper.go # novelfire.net scraping (catalogue, metadata, chapters, ranking) +│ ├── server/ # HTTP API server (server.go + 6 handler files) +│ │ ├── server.go # Server struct, route registration, ListenAndServe +│ │ ├── handlers_scrape.go # POST /scrape, /scrape/book, /scrape/book/range; job status/tasks +│ │ ├── handlers_browse.go # GET /api/browse, /api/search, /api/cover — MinIO-cached browse pages +│ │ ├── handlers_preview.go # GET /api/book-preview, /api/chapter-text-preview — live scrape, no store writes +│ │ ├── handlers_audio.go # POST /api/audio, GET /api/audio-proxy, voice samples, presign +│ │ ├── handlers_progress.go # GET/POST/DELETE /api/progress +│ │ ├── handlers_ranking.go # GET /api/ranking, /api/cover +│ │ └── helpers.go # stripMarkdown, hardcoded voice list fallback +│ ├── storage/ # Persistence layer (PocketBase + MinIO) +│ │ ├── store.go # Store interface — single abstraction for server + orchestrator +│ │ ├── hybrid.go # HybridStore: routes structured data → PocketBase, blobs → MinIO +│ │ ├── pocketbase.go # PocketBase REST admin client (7 collections, auth, schema bootstrap) +│ │ ├── minio.go # MinIO client (3 buckets: chapters, audio, browse) +│ │ └── coverutil.go # Best-effort cover image downloader → browse bucket +│ └── scraper/ +│ ├── interfaces.go # NovelScraper interface + domain types (BookMeta, ChapterRef, etc.) +│ └── htmlutil/htmlutil.go # HTML parsing helpers (NodeToMarkdown, ResolveURL, etc.) ``` ## Key Concepts -- **Orchestrator**: Manages concurrency - catalogue streaming → per-book metadata goroutines → chapter worker pool -- **Browser Client**: 3 strategies (content/scrape/cdp) via Browserless Chrome container -- **Writer**: Writes metadata.yaml and chapter markdown files to `static/books/{slug}/vol-0/1-50/` -- **Server**: HTTP API with async scrape jobs, UI for browsing books/chapters, chapter-text endpoint for TTS +- **Orchestrator**: Catalogue stream → per-book goroutines (metadata + chapter list) → shared chapter work channel → N worker goroutines (chapter text). Scrape jobs tracked in PocketBase `scraping_tasks`. +- **Storage**: `HybridStore` implements the `Store` interface. PocketBase holds structured records (`books`, `chapters_idx`, `ranking`, `progress`, `audio_cache`, `app_users`, `scraping_tasks`). MinIO holds blobs (chapter markdown, audio MP3s, browse HTML snapshots, cover images). +- **Browser Client**: Production uses `NewDirectHTTPClient` (plain HTTP, no Browserless). Browserless variants (content/scrape/cdp) exist in `browser/` but are only wired for the `save-browse` subcommand. +- **Preview**: `GET /api/book-preview/{slug}` scrapes metadata + chapter list live without persisting anything — used when a book is not yet in the library. On first visit, metadata and chapter index are auto-saved to PocketBase in the background. +- **Server**: 24 HTTP endpoints. Async scrape jobs (mutex, 409 on concurrent), in-flight dedup for audio generation, MinIO-backed browse page cache with mem-cache fallback. ## Commands @@ -30,60 +45,138 @@ scraper/ # Build cd scraper && go build -o bin/scraper ./cmd/scraper -# One-shot scrape (full catalogue) +# Full catalogue scrape (one-shot) ./bin/scraper run # Single book ./bin/scraper run --url https://novelfire.net/book/xxx +# Re-scrape a book already in the DB (uses stored source_url) +./bin/scraper refresh + # HTTP server ./bin/scraper serve -# Tests +# Capture browse pages to MinIO via SingleFile CLI (requires SINGLEFILE_PATH + BROWSERLESS_URL) +./bin/scraper save-browse + +# Tests (unit only — integration tests require live services) +cd scraper && go test ./... -short + +# All tests (requires MinIO + PocketBase + Browserless) cd scraper && go test ./... ``` ## Environment Variables +### Scraper (Go) + | Variable | Description | Default | |----------|-------------|---------| -| BROWSERLESS_URL | Browserless Chrome endpoint | http://localhost:3030 | -| BROWSERLESS_STRATEGY | content \| scrape \| cdp | content | -| SCRAPER_WORKERS | Chapter goroutines | NumCPU | -| SCRAPER_STATIC_ROOT | Output directory | ./static/books | -| SCRAPER_HTTP_ADDR | HTTP listen address | :8080 | -| KOKORO_URL | Kokoro TTS endpoint | http://localhost:8880 | -| KOKORO_VOICE | Default TTS voice | af_bella | -| LOG_LEVEL | debug \| info \| warn \| error | info | +| `LOG_LEVEL` | `debug\|info\|warn\|error` | `info` | +| `SCRAPER_HTTP_ADDR` | HTTP listen address | `:8080` | +| `SCRAPER_WORKERS` | Chapter goroutines | `NumCPU` | +| `SCRAPER_TIMEOUT` | Per-request HTTP timeout (seconds) | `90` | +| `KOKORO_URL` | Kokoro-FastAPI TTS base URL | `https://kokoro.kalekber.cc` | +| `KOKORO_VOICE` | Default TTS voice | `af_bella` | +| `MINIO_ENDPOINT` | MinIO S3 API host:port | `localhost:9000` | +| `MINIO_PUBLIC_ENDPOINT` | Public MinIO endpoint for presigned URLs | `""` | +| `MINIO_ACCESS_KEY` | MinIO access key | `admin` | +| `MINIO_SECRET_KEY` | MinIO secret key | `changeme123` | +| `MINIO_USE_SSL` | TLS for internal MinIO connection | `false` | +| `MINIO_PUBLIC_USE_SSL` | TLS for public presigned URL endpoint | `true` | +| `MINIO_BUCKET_CHAPTERS` | Chapter markdown bucket | `libnovel-chapters` | +| `MINIO_BUCKET_AUDIO` | Audio MP3 bucket | `libnovel-audio` | +| `MINIO_BUCKET_BROWSE` | Browse HTML + cover image bucket | `libnovel-browse` | +| `POCKETBASE_URL` | PocketBase base URL | `http://localhost:8090` | +| `POCKETBASE_ADMIN_EMAIL` | PocketBase admin email | `admin@libnovel.local` | +| `POCKETBASE_ADMIN_PASSWORD` | PocketBase admin password | `changeme123` | +| `BROWSERLESS_URL` | Browserless WS endpoint (save-browse only) | `http://localhost:3030` | +| `SINGLEFILE_PATH` | SingleFile CLI binary path (save-browse only) | `single-file` | + +### UI (SvelteKit) + +| Variable | Description | Default | +|----------|-------------|---------| +| `AUTH_SECRET` | HMAC signing secret for auth tokens | `dev_secret_change_in_production` | +| `SCRAPER_API_URL` | Internal URL of the Go scraper | `http://localhost:8080` | +| `POCKETBASE_URL` | PocketBase base URL | `http://localhost:8090` | +| `POCKETBASE_ADMIN_EMAIL` | PocketBase admin email | `admin@libnovel.local` | +| `POCKETBASE_ADMIN_PASSWORD` | PocketBase admin password | `changeme123` | +| `PUBLIC_MINIO_PUBLIC_URL` | Browser-visible MinIO URL (presigned links) | `http://localhost:9000` | ## Docker ```bash -docker-compose up -d # Starts browserless, kokoro, scraper +docker-compose up -d # Starts: minio, minio-init, pocketbase, pb-init, scraper, ui ``` +Services: + +| Service | Port(s) | Role | +|---------|---------|------| +| `minio` | `9000` (S3 API), `9001` (console) | Object storage | +| `minio-init` | — | One-shot bucket creation then exits | +| `pocketbase` | `8090` | Structured data store | +| `pb-init` | — | One-shot PocketBase collection bootstrap then exits | +| `scraper` | `8080` | Go scraper HTTP API | +| `ui` | `5252` → internal `3000` | SvelteKit frontend | + +Kokoro and Browserless are **external services** — not in docker-compose. + +## HTTP API Endpoints (Go scraper) + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/health` | Liveness probe | +| `POST` | `/scrape` | Enqueue full catalogue scrape | +| `POST` | `/scrape/book` | Enqueue single-book scrape `{url}` | +| `POST` | `/scrape/book/range` | Enqueue range scrape `{url, from, to?}` | +| `GET` | `/api/scrape/status` | Current scrape job status | +| `GET` | `/api/scrape/tasks` | All scrape task records | +| `GET` | `/api/browse` | Browse novelfire catalogue (MinIO-cached) | +| `GET` | `/api/search` | Search local + remote `?q=` | +| `GET` | `/api/ranking` | Ranking list | +| `GET` | `/api/cover/{domain}/{slug}` | Proxy cover image from MinIO | +| `GET` | `/api/book-preview/{slug}` | Live metadata + chapter list (no store write) | +| `GET` | `/api/chapter-text-preview/{slug}/{n}` | Live chapter text (no store write) | +| `POST` | `/api/reindex/{slug}` | Rebuild chapters_idx from MinIO | +| `GET` | `/api/chapter-text/{slug}/{n}` | Chapter text (markdown stripped) | +| `POST` | `/api/audio/{slug}/{n}` | Trigger Kokoro TTS generation | +| `GET` | `/api/audio-proxy/{slug}/{n}` | Proxy generated audio | +| `POST` | `/api/audio/voice-samples` | Pre-generate voice samples | +| `GET` | `/api/voices` | List available Kokoro voices | +| `GET` | `/api/presign/chapter/{slug}/{n}` | Presigned MinIO URL for chapter | +| `GET` | `/api/presign/audio/{slug}/{n}` | Presigned MinIO URL for audio | +| `GET` | `/api/presign/voice-sample/{voice}` | Presigned MinIO URL for voice sample | +| `GET` | `/api/progress` | Get reading progress (session-scoped) | +| `POST` | `/api/progress/{slug}` | Set reading progress | +| `DELETE` | `/api/progress/{slug}` | Delete reading progress | + ## Code Patterns -- Uses `log/slog` for structured logging -- Context-based cancellation throughout -- Worker pool pattern in orchestrator (channel + goroutines) -- Mutex for single async job (409 on concurrent scrape requests) +- `log/slog` for structured logging throughout +- Context-based cancellation on all network calls and goroutines +- Worker pool pattern in orchestrator (buffered channel + WaitGroup) +- Single async scrape job enforced by mutex; 409 on concurrent requests; job state persisted to `scraping_tasks` in PocketBase +- `Store` interface decouples all persistence — pass it around, never touch MinIO/PocketBase clients directly outside `storage/` +- Auth: custom HMAC-signed token (`userId:username:role.`) in `libnovel_auth` cookie; signed with `AUTH_SECRET` ## AI Context Tips -- Primary files to modify: `orchestrator.go`, `server.go`, `scraper.go`, `browser/*.go` -- To add new source: implement `NovelScraper` interface from `internal/scraper/interfaces.go` -- Skip `static/` directory - generated content, not source +- **Primary files to modify**: `orchestrator.go`, `server/handlers_*.go`, `novelfire/scraper.go`, `storage/hybrid.go`, `storage/pocketbase.go` +- **To add a new scrape source**: implement `NovelScraper` from `internal/scraper/interfaces.go` +- **To add a new API endpoint**: add handler in the appropriate `handlers_*.go` file, register in `server.go` `ListenAndServe()` +- **Storage changes**: update `Store` interface in `store.go`, implement on `HybridStore` (hybrid.go) and `PocketBaseStore`/`MinioClient` as needed; update mock in `orchestrator_test.go` +- **Skip**: `scraper/bin/` (compiled binary), MinIO/PocketBase data volumes -## Speed Up AI Sessions (Optional) +## iOS App -For faster AI context loading, use **Context7** (free, local indexing): +See `ios/AGENTS.md` for full iOS/SwiftUI conventions. -```bash -# Install and index once -npx @context7/cli@latest index --path . --ignore .aiignore +## Documentation Tools -# After first run, AI tools will query the index instead of re-scanning files -``` +This project has two MCP-backed documentation tools available. Use them proactively: -VSCode extension: https://marketplace.visualstudio.com/items?itemName=context7.context7 +- **`context7`** — Live Apple SwiftUI/Swift docs, Go stdlib, SvelteKit, and any other library docs. Use before implementing anything non-trivial in Swift/SwiftUI. Example: `use context7 to look up NavigationStack`. +- **`gh_grep`** — Search real-world code on GitHub for implementation patterns. Example: `use gh_grep to find examples of background URLSession in Swift`. diff --git a/docker-compose.yml b/docker-compose.yml index 750c534..e08957b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,82 +1,165 @@ version: "3.9" services: - # ─── Browserless ──────────────────────────────────────────────────────────── - browserless: - image: ghcr.io/browserless/chromium:latest - container_name: libnovel-browserless + # ─── MinIO (object storage for chapter .md files + audio cache) ───────────── + minio: + image: minio/minio:latest + #container_name: libnovel-minio restart: unless-stopped + command: server /data --console-address ":9001" environment: - # Set a token to lock down the endpoint; the scraper reads it via - # BROWSERLESS_TOKEN below. - TOKEN: "${BROWSERLESS_TOKEN:-}" - # Allow up to 10 concurrent browser sessions. - CONCURRENT: "${BROWSERLESS_CONCURRENT:-10}" - # Queue up to 100 requests before returning 429. - QUEUED: "${BROWSERLESS_QUEUED:-100}" - # Per-session timeout in ms. - TIMEOUT: "${BROWSERLESS_TIMEOUT:-60000}" - # Optional webhook URL for Browserless error alerts. - ERROR_ALERT_URL: "${ERROR_ALERT_URL:-}" + MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}" + MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}" ports: - - "3030:3000" - # Shared memory is required for Chrome. - shm_size: "2gb" + - "${MINIO_PORT:-9000}:9000" # S3 API + - "${MINIO_CONSOLE_PORT:-9001}:9001" # Web console + volumes: + - minio_data:/data healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:3000/json/version"] + test: ["CMD", "mc", "ready", "local"] interval: 10s timeout: 5s retries: 5 - # ─── Kokoro-FastAPI (TTS) ──────────────────────────────────────────────────── - # CPU image; swap for ghcr.io/remsky/kokoro-fastapi-gpu:latest on NVIDIA hosts. - # Models are baked in — no volume mount required for the default voice set. - kokoro: - image: ghcr.io/remsky/kokoro-fastapi-cpu:latest - container_name: libnovel-kokoro + # ─── MinIO bucket initialisation ───────────────────────────────────────────── + # Runs once to create the default buckets and then exits. + minio-init: + image: minio/mc:latest + #container_name: libnovel-minio-init + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 $${MINIO_ROOT_USER:-admin} $${MINIO_ROOT_PASSWORD:-changeme123}; + mc mb --ignore-existing local/libnovel-chapters; + mc mb --ignore-existing local/libnovel-audio; + mc mb --ignore-existing local/libnovel-browse; + mc mb --ignore-existing local/libnovel-avatars; + echo 'buckets ready'; + " + environment: + MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}" + MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}" + + # ─── PocketBase (auth + structured data: books, chapters index, ranking, progress) ── + pocketbase: + image: ghcr.io/muchobien/pocketbase:latest + #container_name: libnovel-pocketbase restart: unless-stopped + environment: + # Auto-create superuser on first boot (used by entrypoint.sh) + PB_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" + PB_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" ports: - - "8880:8880" + - "${POCKETBASE_PORT:-8090}:8090" + volumes: + - pb_data:/pb_data healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8880/health"] - interval: 15s + test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/health"] + interval: 10s timeout: 5s retries: 5 + # ─── PocketBase collection bootstrap ──────────────────────────────────────── + # One-shot init container: creates all required collections via the admin API + # and exits. Idempotent — safe to run on every `docker compose up`. + pb-init: + image: alpine:3.19 + depends_on: + pocketbase: + condition: service_healthy + environment: + POCKETBASE_URL: "http://pocketbase:8090" + POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" + POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" + volumes: + - ./scripts/pb-init.sh:/pb-init.sh:ro + entrypoint: ["sh", "/pb-init.sh"] + # ─── Scraper ───────────────────────────────────────────────────────────────── scraper: build: context: ./scraper dockerfile: Dockerfile - container_name: libnovel-scraper + args: + VERSION: "${GIT_TAG:-dev}" + COMMIT: "${GIT_COMMIT:-unknown}" + #container_name: libnovel-scraper restart: unless-stopped depends_on: - kokoro: + pb-init: + condition: service_completed_successfully + pocketbase: + condition: service_healthy + minio: condition: service_healthy environment: - BROWSERLESS_URL: "http://browserless:3000" - BROWSERLESS_TOKEN: "${BROWSERLESS_TOKEN:-}" - # content | scrape | cdp | direct — swap to test different strategies. - BROWSERLESS_STRATEGY: "${BROWSERLESS_STRATEGY:-direct}" - # Strategy for URL retrieval (chapter list). Default: content (browserless) - BROWSERLESS_URL_STRATEGY: "${BROWSERLESS_URL_STRATEGY:-content}" # 0 → defaults to NumCPU inside the container. SCRAPER_WORKERS: "${SCRAPER_WORKERS:-0}" - SCRAPER_STATIC_ROOT: "/app/static/books" SCRAPER_HTTP_ADDR: ":8080" LOG_LEVEL: "debug" # Kokoro-FastAPI TTS endpoint. - KOKORO_URL: "${KOKORO_URL:-http://localhost:8880}" + KOKORO_URL: "${KOKORO_URL:-https://kokoro.kalekber.cc}" KOKORO_VOICE: "${KOKORO_VOICE:-af_bella}" + # MinIO / S3 object storage + MINIO_ENDPOINT: "minio:9000" + MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-admin}" + MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-changeme123}" + MINIO_USE_SSL: "false" + MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}" + MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}" + MINIO_BUCKET_BROWSE: "${MINIO_BUCKET_BROWSE:-libnovel-browse}" + MINIO_BUCKET_AVATARS: "${MINIO_BUCKET_AVATARS:-libnovel-avatars}" + # Public endpoint used to sign presigned audio URLs so browsers can reach them. + # Leave empty to use MINIO_ENDPOINT (fine for local dev). + MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}" + MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL:-true}" + # SingleFile CLI path for save-browse subcommand + SINGLEFILE_PATH: "${SINGLEFILE_PATH:-single-file}" + # PocketBase + POCKETBASE_URL: "http://pocketbase:8090" + POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" + POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" ports: - - "8080:8080" - volumes: - - static_books:/app/static/books + - "${SCRAPER_PORT:-8080}:8080" healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"] interval: 15s timeout: 5s retries: 3 + # ─── SvelteKit UI ──────────────────────────────────────────────────────────── + ui: + build: + context: ./ui + dockerfile: Dockerfile + args: + BUILD_VERSION: "${GIT_TAG:-dev}" + BUILD_COMMIT: "${GIT_COMMIT:-unknown}" + # container_name: libnovel-ui + restart: unless-stopped + depends_on: + pb-init: + condition: service_completed_successfully + scraper: + condition: service_healthy + pocketbase: + condition: service_healthy + environment: + SCRAPER_API_URL: "http://scraper:8080" + POCKETBASE_URL: "http://pocketbase:8090" + POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" + POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" + PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}" + ports: + - "${UI_PORT:-5252}:3000" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] + interval: 15s + timeout: 5s + retries: 3 + volumes: - static_books: + minio_data: + pb_data: diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..b1f0a6b --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,14 @@ +# Xcode build artifacts — regenerate with: xcodegen generate --spec project.yml +xcuserdata/ +*.xcuserstate +*.xcworkspace/xcuserdata/ +DerivedData/ +build/ + +# Swift Package Manager — resolved by Xcode on first open +LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/ +.build/ +# Package.resolved is committed so SPM builds are reproducible + +# OS +.DS_Store diff --git a/ios/AGENTS.md b/ios/AGENTS.md new file mode 100644 index 0000000..5992ee8 --- /dev/null +++ b/ios/AGENTS.md @@ -0,0 +1,87 @@ +# LibNovel iOS App + +SwiftUI app targeting iOS 17+. Consumes the Go scraper HTTP API for books, chapters, and audio. Uses MinIO presigned URLs for media playback and downloads. + +## Project Structure + +``` +ios/LibNovel/LibNovel/ +├── App/ # LibNovelApp.swift, ContentView.swift, RootTabView.swift +├── Models/ # Models.swift (all domain types) +├── Networking/ # APIClient.swift (URLSession-based HTTP client) +├── Services/ # AudioPlayerService, AudioDownloadService, AuthStore, +│ # BookVoicePreferences, NetworkMonitor +├── ViewModels/ # One per view/feature (HomeViewModel, BrowseViewModel, etc.) +├── Views/ +│ ├── Auth/ # AuthView +│ ├── BookDetail/ # BookDetailView, CommentsView +│ ├── Browse/ # BrowseView (infinite scroll shelves) +│ ├── ChapterReader/ # ChapterReaderView, DownloadAudioButton +│ ├── Common/ # CommonViews (shared reusable components) +│ ├── Components/ # OfflineBanner +│ ├── Downloads/ # DownloadsView, DownloadQueueButton +│ ├── Home/ # HomeView +│ ├── Library/ # LibraryView (2-col grid, filters) +│ ├── Player/ # PlayerViews (floating FAB, compact, full-screen) +│ ├── Profile/ # ProfileView, VoiceSelectionView, UserProfileView, etc. +│ └── Search/ # SearchView +└── Extensions/ # NavDestination.swift, String+App.swift, Color+App.swift +``` + +## iOS / Swift Conventions + +- **Deployment target**: iOS 17.0 — use iOS 17+ APIs freely. +- **Observable pattern**: The codebase currently uses `@StateObject` / `ObservableObject` / `@Published`. When adding new types, prefer the **`@Observable` macro** (iOS 17+) over `ObservableObject`. Do not refactor existing types unless explicitly asked. +- **Navigation**: Use `NavigationStack` (not `NavigationView`). Use `.navigationDestination(for:)` for type-safe routing. +- **Concurrency**: Use `async/await` and structured concurrency. Avoid callback-based APIs and `DispatchQueue.main.async` — prefer `@MainActor` or `await MainActor.run`. +- **State management**: Prefer `@State` + `@Binding` for local UI state. Use environment objects for app-wide services (authStore, audioPlayer, downloadService, networkMonitor). +- **SwiftData**: Not currently used. Do not introduce SwiftData without discussion. +- **SF Symbols**: Use `Image(systemName:)` for icons. No emoji in UI unless already present. + +## Key Patterns + +- **Download keys**: Use `::` as separator (e.g., `"slug::chapter-1::voice"`), never `-`. Slugs contain hyphens. +- **Voice fallback chain**: book override → global default → `"af_bella"`. See `BookVoicePreferences.voiceWithFallback()`. +- **Offline handling**: Wrap view bodies in `VStack` with `OfflineBanner` at top. Use `NetworkMonitor` (environment object) to gate network calls. Suppress network errors silently when offline via `ErrorAlertModifier`. +- **Audio playback priority**: local file → MinIO presigned URL → trigger TTS generation. +- **Progress display**: Show decimal % when < 10% (e.g., "3.4%"), rounded when >= 10% (e.g., "47%"). +- **Cover images**: Always proxy via `/api/cover/{domain}/{slug}` — never link directly to source. + +## Networking + +`APIClient.swift` wraps all Go scraper API calls. When adding new endpoints: + +1. Add a method to `APIClient`. +2. Keep error handling consistent — throw typed errors, let ViewModels catch and set `errorMessage`. +3. All requests are relative to `SCRAPER_API_URL` (configured at build time via xcconfig or environment). + +## Using Documentation Tools + +When writing or reviewing SwiftUI/Swift code: + +- Use `context7` to look up current Apple SwiftUI/Swift documentation before implementing anything non-trivial. Apple's APIs evolve fast — do not rely on training data alone. +- Use `gh_grep` to find real-world Swift patterns when unsure how something is typically implemented. + +Example prompts: +- "How does `.searchable` work in iOS 17? use context7" +- "Show me examples of `@Observable` with async tasks. use context7" +- "How do other apps implement background URLSession downloads in Swift? use gh_grep" + +## UI/UX Skill + +For any iOS view work, always load the `ios-ux` skill at the start of the task: + +``` +skill({ name: "ios-ux" }) +``` + +This skill defines the full design system, animation rules, haptic feedback policy, accessibility checklist, performance guidelines, and offline handling requirements. It also governs how to handle screenshot-based reviews (analyze → suggest → confirm before applying). + +## What to Avoid + +- `NavigationView` — deprecated, use `NavigationStack` +- `ObservableObject` / `@Published` for new types — prefer `@Observable` +- `DispatchQueue.main.async` — prefer `@MainActor` +- Force unwrapping optionals +- Hardcoded color literals — use `Color+App.swift` extensions or semantic colors +- Adding new dependencies (SPM packages) without discussion diff --git a/ios/LibNovel/.gitignore b/ios/LibNovel/.gitignore new file mode 100644 index 0000000..930f1d0 --- /dev/null +++ b/ios/LibNovel/.gitignore @@ -0,0 +1,10 @@ +# Fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output +fastlane/README.md + +# Bundler +.bundle +vendor/bundle diff --git a/ios/LibNovel/ExportOptions.plist b/ios/LibNovel/ExportOptions.plist new file mode 100644 index 0000000..804a0c9 --- /dev/null +++ b/ios/LibNovel/ExportOptions.plist @@ -0,0 +1,21 @@ + + + + + method + app-store + teamID + GHZXC6FVMU + uploadBitcode + + uploadSymbols + + signingStyle + manual + provisioningProfiles + + com.kalekber.LibNovel + LibNovel Distribution + + + diff --git a/ios/LibNovel/Gemfile b/ios/LibNovel/Gemfile new file mode 100644 index 0000000..7a118b4 --- /dev/null +++ b/ios/LibNovel/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane" diff --git a/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj new file mode 100644 index 0000000..781ac45 --- /dev/null +++ b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj @@ -0,0 +1,772 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */; }; + 07FC69FB9DF3F6073564E489 /* DiscoverViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */; }; + 08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */; }; + 0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 762E378B9BC2161A7AA2CC36 /* Models.swift */; }; + 0B40E3DCE82EBEA7C4ECF148 /* AvatarCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */; }; + 192F82518CB8763775E33B38 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79133D9FA697D1909C8D3973 /* SearchView.swift */; }; + 1945DD2D0DF497FE66FAAF90 /* BookVoicePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */; }; + 1964D61094D4731227384F3A /* VoiceSelectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */; }; + 2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */; }; + 2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */; }; + 3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC338B05EA6DB22900712000 /* LibraryViewModel.swift */; }; + 367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5C115992F1CE2326236765 /* RootTabView.swift */; }; + 41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC6F837FF2E902E334ED72E /* String+App.swift */; }; + 4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C918833E173D6B44D06955 /* LibNovelTests.swift */; }; + 58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; }; + 5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; }; + 5F7409635F6563E44C836390 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */; }; + 62B42DB777F53856C57CB6AF /* OfflineBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */; }; + 64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B17D50389C6C98FC78BDBC /* ProfileView.swift */; }; + 65CA672C02F367F72F18F8B8 /* AudioDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94730324A6BD9D6A772286BB /* AudioDownloadService.swift */; }; + 749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39DE056C37FBC5EED8771821 /* BookDetailView.swift */; }; + 774CFCDA8A13311DF85FF051 /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8175390266E8C6CF1437A229 /* DownloadsView.swift */; }; + 7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A776719B77EDDB5E44743B0 /* Assets.xcassets */; }; + 7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; }; + 880D411C936F7BA92AF83383 /* DownloadQueueButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */; }; + 8B02625CA1B93118B63E9C9D /* VoiceSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */; }; + 9407F80F454D0248D5C779A6 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */; }; + 94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; }; + 9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; }; + 9C19B17E746FE6A834E53AF3 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F247DE25991F4DB98DF717AA /* UserProfileView.swift */; }; + A7485E99B9ACBCBCCD1EB7B2 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16B9AFE90719BDBC718F0621 /* CommentsView.swift */; }; + A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; }; + BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; }; + C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; }; + CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; }; + DFA7EB1B0BD53F68FE1335C8 /* DownloadAudioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35942111986E54CC0E83A391 /* DownloadAudioButton.swift */; }; + E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; }; + E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; }; + ED54860A709FED5A8CBF4EEB /* AccountMenuSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */; }; + EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6268D60803940CBD38FB921 /* HomeView.swift */; }; + F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89FD8F46747CA653C5203D /* CommonViews.swift */; }; + F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */; }; + FB32F3772CA09684F00497F3 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B593F179EC3E9112126B540B /* APIClient.swift */; }; + FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 698AC3AA533BC05C985595D0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A10A669C0C8B43078C0FEE9F /* Project object */; + proxyType = 1; + remoteGlobalIDString = D039EDECDE3998D8534BB680; + remoteInfo = LibNovel; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = ""; }; + 16B9AFE90719BDBC718F0621 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = ""; }; + 16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadQueueButton.swift; sourceTree = ""; }; + 1B8BF3DB582A658386E402C7 /* LibNovel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovel.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVoicePreferences.swift; sourceTree = ""; }; + 1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; + 1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = ""; }; + 235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = ""; }; + 35942111986E54CC0E83A391 /* DownloadAudioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadAudioButton.swift; sourceTree = ""; }; + 39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = ""; }; + 3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; + 4B820081FA4817765A39939A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelApp.swift; sourceTree = ""; }; + 5A776719B77EDDB5E44743B0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 762E378B9BC2161A7AA2CC36 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = ""; }; + 79133D9FA697D1909C8D3973 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; + 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavDestination.swift; sourceTree = ""; }; + 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; + 8175390266E8C6CF1437A229 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = ""; }; + 81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderView.swift; sourceTree = ""; }; + 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = ""; }; + 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderViewModel.swift; sourceTree = ""; }; + 8E89FD8F46747CA653C5203D /* CommonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonViews.swift; sourceTree = ""; }; + 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; + 94730324A6BD9D6A772286BB /* AudioDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDownloadService.swift; sourceTree = ""; }; + 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = ""; }; + 9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = ""; }; + A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionView.swift; sourceTree = ""; }; + AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoverViewModel.swift; sourceTree = ""; }; + AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMenuSheet.swift; sourceTree = ""; }; + B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = ""; }; + B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; + C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; + C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; + CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionViewModel.swift; sourceTree = ""; }; + D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = ""; }; + DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = ""; }; + F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineBanner.swift; sourceTree = ""; }; + F219788AE5ACBD6F240674F5 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = ""; }; + F247DE25991F4DB98DF717AA /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; + FC338B05EA6DB22900712000 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = ""; }; + FEC6F837FF2E902E334ED72E /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + EFE3211B202EDF04EB141EFB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2C0FB0EDFF9B3E24B97F4214 /* Resources */ = { + isa = PBXGroup; + children = ( + 5A776719B77EDDB5E44743B0 /* Assets.xcassets */, + ); + path = Resources; + sourceTree = ""; + }; + 2C57B93EAF19A3B18E7B7E87 /* Views */ = { + isa = PBXGroup; + children = ( + 2F18D1275D6022B9847E310E /* Auth */, + FB5C0D4925633786D28C6DE3 /* BookDetail */, + 8E8AAA58A33084ADB8AEA80C /* Browse */, + 4EAB87A1ED4943A311F26F84 /* ChapterReader */, + 5D5809803A3D74FAE19DB218 /* Common */, + 9180FAFE96724B8AACFA9859 /* Components */, + 3881CBFE9730C6422BE6F03D /* Downloads */, + 811FC0F6B9C209D6EC8543BD /* Home */, + FA994FD601E79EC811D822A4 /* Library */, + 89F2CB14192E7D7565A588E0 /* Player */, + 3DB66C5703A4CCAFFA1B7AFE /* Profile */, + 474BE4FC0353C2DD8D8425D1 /* Search */, + ); + path = Views; + sourceTree = ""; + }; + 2F18D1275D6022B9847E310E /* Auth */ = { + isa = PBXGroup; + children = ( + 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */, + ); + path = Auth; + sourceTree = ""; + }; + 3881CBFE9730C6422BE6F03D /* Downloads */ = { + isa = PBXGroup; + children = ( + 16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */, + 8175390266E8C6CF1437A229 /* DownloadsView.swift */, + ); + path = Downloads; + sourceTree = ""; + }; + 3DB66C5703A4CCAFFA1B7AFE /* Profile */ = { + isa = PBXGroup; + children = ( + AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */, + 775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */, + C0B17D50389C6C98FC78BDBC /* ProfileView.swift */, + F247DE25991F4DB98DF717AA /* UserProfileView.swift */, + A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */, + ); + path = Profile; + sourceTree = ""; + }; + 426F7C5465758645B93A1AB1 /* Networking */ = { + isa = PBXGroup; + children = ( + B593F179EC3E9112126B540B /* APIClient.swift */, + ); + path = Networking; + sourceTree = ""; + }; + 474BE4FC0353C2DD8D8425D1 /* Search */ = { + isa = PBXGroup; + children = ( + 79133D9FA697D1909C8D3973 /* SearchView.swift */, + ); + path = Search; + sourceTree = ""; + }; + 4EAB87A1ED4943A311F26F84 /* ChapterReader */ = { + isa = PBXGroup; + children = ( + 81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */, + 35942111986E54CC0E83A391 /* DownloadAudioButton.swift */, + ); + path = ChapterReader; + sourceTree = ""; + }; + 5D5809803A3D74FAE19DB218 /* Common */ = { + isa = PBXGroup; + children = ( + 8E89FD8F46747CA653C5203D /* CommonViews.swift */, + ); + path = Common; + sourceTree = ""; + }; + 6318D3C6F0DC6C8E2C377103 /* Products */ = { + isa = PBXGroup; + children = ( + 1B8BF3DB582A658386E402C7 /* LibNovel.app */, + 235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 646952B9CE927F8038FF0A13 /* LibNovelTests */ = { + isa = PBXGroup; + children = ( + B4C918833E173D6B44D06955 /* LibNovelTests.swift */, + ); + path = LibNovelTests; + sourceTree = ""; + }; + 80148B5E27BD0A3DEDB3ADAA /* Models */ = { + isa = PBXGroup; + children = ( + 762E378B9BC2161A7AA2CC36 /* Models.swift */, + ); + path = Models; + sourceTree = ""; + }; + 811FC0F6B9C209D6EC8543BD /* Home */ = { + isa = PBXGroup; + children = ( + D6268D60803940CBD38FB921 /* HomeView.swift */, + ); + path = Home; + sourceTree = ""; + }; + 89F2CB14192E7D7565A588E0 /* Player */ = { + isa = PBXGroup; + children = ( + DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */, + ); + path = Player; + sourceTree = ""; + }; + 8E8AAA58A33084ADB8AEA80C /* Browse */ = { + isa = PBXGroup; + children = ( + 1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */, + ); + path = Browse; + sourceTree = ""; + }; + 9180FAFE96724B8AACFA9859 /* Components */ = { + isa = PBXGroup; + children = ( + F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */, + ); + path = Components; + sourceTree = ""; + }; + 9AF55E5D62F980C72431782A = { + isa = PBXGroup; + children = ( + A28A184E73B15138A4D13F31 /* LibNovel */, + 646952B9CE927F8038FF0A13 /* LibNovelTests */, + 6318D3C6F0DC6C8E2C377103 /* Products */, + ); + indentWidth = 4; + sourceTree = ""; + tabWidth = 4; + usesTabs = 0; + }; + A28A184E73B15138A4D13F31 /* LibNovel */ = { + isa = PBXGroup; + children = ( + FE92158CC5DA9AD446062724 /* App */, + FD5EDEE9747643D45CA6423E /* Extensions */, + 80148B5E27BD0A3DEDB3ADAA /* Models */, + 426F7C5465758645B93A1AB1 /* Networking */, + 2C0FB0EDFF9B3E24B97F4214 /* Resources */, + DA6F6F625578875F3E74F1D3 /* Services */, + B6916C5C762A37AB1279DF44 /* ViewModels */, + 2C57B93EAF19A3B18E7B7E87 /* Views */, + ); + path = LibNovel; + sourceTree = ""; + }; + B6916C5C762A37AB1279DF44 /* ViewModels */ = { + isa = PBXGroup; + children = ( + 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */, + 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */, + 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */, + AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */, + 3AB2E843D93461074A89A171 /* HomeViewModel.swift */, + FC338B05EA6DB22900712000 /* LibraryViewModel.swift */, + 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */, + 10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */, + CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + DA6F6F625578875F3E74F1D3 /* Services */ = { + isa = PBXGroup; + children = ( + 94730324A6BD9D6A772286BB /* AudioDownloadService.swift */, + DB13E89E50529E3081533A66 /* AudioPlayerService.swift */, + F219788AE5ACBD6F240674F5 /* AuthStore.swift */, + 1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */, + 1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */, + ); + path = Services; + sourceTree = ""; + }; + FA994FD601E79EC811D822A4 /* Library */ = { + isa = PBXGroup; + children = ( + C21107BECA55C07416E0CB8B /* LibraryView.swift */, + ); + path = Library; + sourceTree = ""; + }; + FB5C0D4925633786D28C6DE3 /* BookDetail */ = { + isa = PBXGroup; + children = ( + 39DE056C37FBC5EED8771821 /* BookDetailView.swift */, + 16B9AFE90719BDBC718F0621 /* CommentsView.swift */, + ); + path = BookDetail; + sourceTree = ""; + }; + FD5EDEE9747643D45CA6423E /* Extensions */ = { + isa = PBXGroup; + children = ( + 9D83BB88C4306BE7A4F947CB /* Color+App.swift */, + 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */, + FEC6F837FF2E902E334ED72E /* String+App.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + FE92158CC5DA9AD446062724 /* App */ = { + isa = PBXGroup; + children = ( + 4B820081FA4817765A39939A /* ContentView.swift */, + 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */, + 2D5C115992F1CE2326236765 /* RootTabView.swift */, + ); + path = App; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 5E6D3E8266BFCF0AAF5EC79D /* LibNovelTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 964FF85B62FA35E819BE7661 /* Build configuration list for PBXNativeTarget "LibNovelTests" */; + buildPhases = ( + 247D45B3DB26CAC41FA78A0B /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 9FD4A50EB175FC09D6BFD28D /* PBXTargetDependency */, + ); + name = LibNovelTests; + packageProductDependencies = ( + ); + productName = LibNovelTests; + productReference = 235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + D039EDECDE3998D8534BB680 /* LibNovel */ = { + isa = PBXNativeTarget; + buildConfigurationList = 29B2DE7267A3A4B2D89B32DA /* Build configuration list for PBXNativeTarget "LibNovel" */; + buildPhases = ( + 48661ADCA15B54E048CF694C /* Sources */, + 27446CA4728C022832398376 /* Resources */, + EFE3211B202EDF04EB141EFB /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = LibNovel; + packageProductDependencies = ( + 09584EAB68A07B47F876A062 /* Kingfisher */, + ); + productName = LibNovel; + productReference = 1B8BF3DB582A658386E402C7 /* LibNovel.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A10A669C0C8B43078C0FEE9F /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1600; + }; + buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 9AF55E5D62F980C72431782A; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 6318D3C6F0DC6C8E2C377103 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + D039EDECDE3998D8534BB680 /* LibNovel */, + 5E6D3E8266BFCF0AAF5EC79D /* LibNovelTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 27446CA4728C022832398376 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 247D45B3DB26CAC41FA78A0B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 48661ADCA15B54E048CF694C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FB32F3772CA09684F00497F3 /* APIClient.swift in Sources */, + ED54860A709FED5A8CBF4EEB /* AccountMenuSheet.swift in Sources */, + 65CA672C02F367F72F18F8B8 /* AudioDownloadService.swift in Sources */, + A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */, + 5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */, + 9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */, + 0B40E3DCE82EBEA7C4ECF148 /* AvatarCropView.swift in Sources */, + 749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */, + 7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */, + 1945DD2D0DF497FE66FAAF90 /* BookVoicePreferences.swift in Sources */, + 08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */, + 2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */, + FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */, + 2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */, + E2572692178FD17145FDAF77 /* Color+App.swift in Sources */, + A7485E99B9ACBCBCCD1EB7B2 /* CommentsView.swift in Sources */, + F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */, + 94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */, + 07FC69FB9DF3F6073564E489 /* DiscoverViewModel.swift in Sources */, + DFA7EB1B0BD53F68FE1335C8 /* DownloadAudioButton.swift in Sources */, + 880D411C936F7BA92AF83383 /* DownloadQueueButton.swift in Sources */, + 774CFCDA8A13311DF85FF051 /* DownloadsView.swift in Sources */, + EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */, + C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */, + 032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */, + E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */, + 3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */, + 0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */, + F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */, + 5F7409635F6563E44C836390 /* NetworkMonitor.swift in Sources */, + 62B42DB777F53856C57CB6AF /* OfflineBanner.swift in Sources */, + BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */, + 64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */, + 58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */, + 367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */, + 192F82518CB8763775E33B38 /* SearchView.swift in Sources */, + 41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */, + 9C19B17E746FE6A834E53AF3 /* UserProfileView.swift in Sources */, + 9407F80F454D0248D5C779A6 /* UserProfileViewModel.swift in Sources */, + 8B02625CA1B93118B63E9C9D /* VoiceSelectionView.swift in Sources */, + 1964D61094D4731227384F3A /* VoiceSelectionViewModel.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 9FD4A50EB175FC09D6BFD28D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D039EDECDE3998D8534BB680 /* LibNovel */; + targetProxy = 698AC3AA533BC05C985595D0 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 428871329DC9E7B31FA1664B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel"; + }; + name = Release; + }; + 49CBF0D367E562629E002A4B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel"; + }; + name = Debug; + }; + 8098D4A97F989064EC71E5A1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = GHZXC6FVMU; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LibNovel/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 9C182367114E72FF84D54A2F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc"; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.10; + }; + name = Debug; + }; + D9977A0FA70F052FD0C126D3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Distribution"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = GHZXC6FVMU; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LibNovel/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel; + PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + F9ED141CFB1E2EC6F5E9F089 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc"; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.10; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 29B2DE7267A3A4B2D89B32DA /* Build configuration list for PBXNativeTarget "LibNovel" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8098D4A97F989064EC71E5A1 /* Debug */, + D9977A0FA70F052FD0C126D3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 964FF85B62FA35E819BE7661 /* Build configuration list for PBXNativeTarget "LibNovelTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 49CBF0D367E562629E002A4B /* Debug */, + 428871329DC9E7B31FA1664B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9C182367114E72FF84D54A2F /* Debug */, + F9ED141CFB1E2EC6F5E9F089 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/onevcat/Kingfisher"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 09584EAB68A07B47F876A062 /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */; + productName = Kingfisher; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = A10A669C0C8B43078C0FEE9F /* Project object */; +} diff --git a/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..25d7acb --- /dev/null +++ b/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "ad75ae2d3b8d8b80d99635f65213a3c1092464aa54a86354f850b8317b6fa240", + "pins" : [ + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher", + "state" : { + "revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008", + "version" : "8.8.0" + } + } + ], + "version" : 3 +} diff --git a/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme b/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme new file mode 100644 index 0000000..f271d0d --- /dev/null +++ b/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/LibNovel/LibNovel/App/ContentView.swift b/ios/LibNovel/LibNovel/App/ContentView.swift new file mode 100644 index 0000000..fcf5237 --- /dev/null +++ b/ios/LibNovel/LibNovel/App/ContentView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var authStore: AuthStore + @EnvironmentObject var audioPlayer: AudioPlayerService + + var body: some View { + Group { + if authStore.isAuthenticated { + RootTabView() + } else { + AuthView() + } + } + } +} diff --git a/ios/LibNovel/LibNovel/App/LibNovelApp.swift b/ios/LibNovel/LibNovel/App/LibNovelApp.swift new file mode 100644 index 0000000..4f7c203 --- /dev/null +++ b/ios/LibNovel/LibNovel/App/LibNovelApp.swift @@ -0,0 +1,19 @@ +import SwiftUI + +@main +struct LibNovelApp: App { + @StateObject private var authStore = AuthStore() + @StateObject private var audioPlayer = AudioPlayerService() + @StateObject private var downloadService = AudioDownloadService.shared + @StateObject private var networkMonitor = NetworkMonitor() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(authStore) + .environmentObject(audioPlayer) + .environmentObject(downloadService) + .environmentObject(networkMonitor) + } + } +} diff --git a/ios/LibNovel/LibNovel/App/RootTabView.swift b/ios/LibNovel/LibNovel/App/RootTabView.swift new file mode 100644 index 0000000..65e4a09 --- /dev/null +++ b/ios/LibNovel/LibNovel/App/RootTabView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +// MARK: - Root tab container with persistent mini-player overlay + +struct RootTabView: View { + @EnvironmentObject var authStore: AuthStore + @EnvironmentObject var audioPlayer: AudioPlayerService + + @State private var selectedTab: Tab = .home + @State private var showFullPlayer: Bool = false + @State private var readerIsActive: Bool = false + + /// Live drag offset while the user is dragging the full player down. + @State private var fullPlayerDragOffset: CGFloat = 0 + + enum Tab: Hashable { + case home, library, browse, search + } + + var body: some View { + ZStack(alignment: .bottom) { + TabView(selection: $selectedTab) { + HomeView() + .tabItem { Label("Home", systemImage: "house.fill") } + .tag(Tab.home) + + LibraryView() + .tabItem { Label("Library", systemImage: "book.pages.fill") } + .tag(Tab.library) + + BrowseView() + .tabItem { Label("Discover", systemImage: "sparkles") } + .tag(Tab.browse) + + SearchView() + .tabItem { Label("Search", systemImage: "magnifyingglass") } + .tag(Tab.search) + } + + // Mini player bar — sits above the tab bar, hidden while full player is open + // or while the chapter reader is active (it has its own audio chrome). + if audioPlayer.isActive && !showFullPlayer && !readerIsActive { + MiniPlayerBar(showFullPlayer: $showFullPlayer) + // Lift above the tab bar (approx 49 pt on all devices) + .padding(.bottom, 49) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive) + } + + // Full player — slides up from the bottom as a custom overlay. + if showFullPlayer { + FullPlayerView(onDismiss: { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + showFullPlayer = false + fullPlayerDragOffset = 0 + } + }) + .offset(y: max(fullPlayerDragOffset, 0)) + .gesture( + DragGesture(minimumDistance: 10) + .onChanged { value in + if value.translation.height > 0 { + fullPlayerDragOffset = value.translation.height + } + } + .onEnded { value in + let velocity = value.predictedEndTranslation.height - value.translation.height + if value.translation.height > 120 || velocity > 400 { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + showFullPlayer = false + fullPlayerDragOffset = 0 + } + } else { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + fullPlayerDragOffset = 0 + } + } + } + ) + .transition(.move(edge: .bottom)) + .animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer) + .ignoresSafeArea() + } + } + .animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer) + .onPreferenceChange(HideMiniPlayerKey.self) { hide in + readerIsActive = hide + } + } +} diff --git a/ios/LibNovel/LibNovel/Extensions/Color+App.swift b/ios/LibNovel/LibNovel/Extensions/Color+App.swift new file mode 100644 index 0000000..7f4fe40 --- /dev/null +++ b/ios/LibNovel/LibNovel/Extensions/Color+App.swift @@ -0,0 +1,10 @@ +import SwiftUI + +// MARK: - App accent color (amber — mirrors Tailwind amber-500 #f59e0b) +extension Color { + static let amber = Color(red: 0.96, green: 0.62, blue: 0.04) +} + +extension ShapeStyle where Self == Color { + static var amber: Color { .amber } +} diff --git a/ios/LibNovel/LibNovel/Extensions/NavDestination.swift b/ios/LibNovel/LibNovel/Extensions/NavDestination.swift new file mode 100644 index 0000000..f334016 --- /dev/null +++ b/ios/LibNovel/LibNovel/Extensions/NavDestination.swift @@ -0,0 +1,168 @@ +import SwiftUI + +// MARK: - Navigation destination enum used across all tabs + +enum NavDestination: Hashable { + case book(String) // slug + case chapter(String, Int) // slug + chapter number + case userProfile(String) // username + case browseCategory(sort: String, genre: String, status: String, title: String) // Browse with filters +} + +// MARK: - View extensions for shared navigation + error alert patterns + +extension View { + /// Registers the app-wide navigation destinations for NavDestination values. + /// Apply once per NavigationStack instead of repeating the switch in every tab. + func appNavigationDestination() -> some View { + modifier(AppNavigationDestinationModifier()) + } + + /// Presents a standard "Error" alert driven by an optional String binding. + /// Dismissing the alert sets the binding back to nil. + /// Silently suppresses network errors when offline (banner shows instead). + func errorAlert(_ error: Binding) -> some View { + self.modifier(ErrorAlertModifier(error: error)) + } +} + +// MARK: - Error Alert Modifier + +private struct ErrorAlertModifier: ViewModifier { + @Binding var error: String? + @EnvironmentObject var networkMonitor: NetworkMonitor + + private var shouldShowAlert: Bool { + guard let errorMessage = error else { return false } + + // If offline, suppress common network error messages + if !networkMonitor.isConnected { + let networkKeywords = [ + "internet", + "offline", + "network", + "connection", + "unreachable", + "timed out", + "no data" + ] + + let lowercased = errorMessage.lowercased() + let isNetworkError = networkKeywords.contains { lowercased.contains($0) } + + if isNetworkError { + // Clear the error silently + DispatchQueue.main.async { + self.error = nil + } + return false + } + } + + return true + } + + func body(content: Content) -> some View { + content + .alert("Error", isPresented: Binding( + get: { shouldShowAlert }, + set: { if !$0 { error = nil } } + )) { + Button("OK") { error = nil } + } message: { + Text(error ?? "") + } + } +} + +// MARK: - Navigation destination modifier + +private struct AppNavigationDestinationModifier: ViewModifier { + @Namespace private var zoomNamespace + + func body(content: Content) -> some View { + if #available(iOS 18.0, *) { + content + .navigationDestination(for: NavDestination.self) { dest in + switch dest { + case .book(let slug): + BookDetailView(slug: slug) + .navigationTransition(.zoom(sourceID: slug, in: zoomNamespace)) + case .chapter(let slug, let n): + ChapterReaderView(slug: slug, chapterNumber: n) + case .userProfile(let username): + UserProfileView(username: username) + case .browseCategory(let sort, let genre, let status, let title): + BrowseCategoryView(sort: sort, genre: genre, status: status, title: title) + } + } + // Expose namespace to child views via environment + .environment(\.bookZoomNamespace, zoomNamespace) + } else { + content + .navigationDestination(for: NavDestination.self) { dest in + switch dest { + case .book(let slug): BookDetailView(slug: slug) + case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n) + case .userProfile(let username): UserProfileView(username: username) + case .browseCategory(let sort, let genre, let status, let title): + BrowseCategoryView(sort: sort, genre: genre, status: status, title: title) + } + } + } + } +} + +// MARK: - Environment key for zoom namespace + +struct BookZoomNamespaceKey: EnvironmentKey { + static var defaultValue: Namespace.ID? { nil } +} + +extension EnvironmentValues { + var bookZoomNamespace: Namespace.ID? { + get { self[BookZoomNamespaceKey.self] } + set { self[BookZoomNamespaceKey.self] = newValue } + } +} + +// MARK: - Preference key: suppress mini player overlay (used by ChapterReaderView) + +struct HideMiniPlayerKey: PreferenceKey { + static var defaultValue = false + static func reduce(value: inout Bool, nextValue: () -> Bool) { + value = value || nextValue() + } +} + +extension View { + /// Signal to the root overlay that the mini player should be hidden. + func hideMiniPlayer() -> some View { + preference(key: HideMiniPlayerKey.self, value: true) + } +} + +// MARK: - Cover card zoom source modifier + +/// Apply this to any cover image that should be a zoom source for book navigation. +/// Falls back to a no-op on iOS 17 or when no namespace is available. +struct BookCoverZoomSource: ViewModifier { + let slug: String + @Environment(\.bookZoomNamespace) private var namespace + + func body(content: Content) -> some View { + if #available(iOS 18.0, *), let ns = namespace { + content.matchedTransitionSource(id: slug, in: ns) + } else { + content + } + } +} + +extension View { + /// Marks a cover image as the zoom source for a book's navigation transition. + func bookCoverZoomSource(slug: String) -> some View { + modifier(BookCoverZoomSource(slug: slug)) + } +} + diff --git a/ios/LibNovel/LibNovel/Extensions/String+App.swift b/ios/LibNovel/LibNovel/Extensions/String+App.swift new file mode 100644 index 0000000..d2287e5 --- /dev/null +++ b/ios/LibNovel/LibNovel/Extensions/String+App.swift @@ -0,0 +1,41 @@ +import Foundation + +// MARK: - String helpers for display purposes + +extension String { + /// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago", + /// or "(One)4 years ago" where the number is attached without a preceding space). + func strippingTrailingDate() -> String { + let units = ["second", "minute", "hour", "day", "week", "month", "year"] + let lower = self.lowercased() + for unit in units { + for suffix in [unit + "s ago", unit + " ago"] { + guard let suffixRange = lower.range(of: suffix, options: .backwards) else { continue } + // Everything before the suffix + let before = String(self[self.startIndex ..< suffixRange.lowerBound]) + let trimmed = before.trimmingCharacters(in: .whitespaces) + // Strip trailing digits (the numeric count, which may be attached without a space) + var result = trimmed + while let last = result.last, last.isNumber { + result.removeLast() + } + result = result.trimmingCharacters(in: .whitespaces) + if result != trimmed { + // We actually stripped some digits — return cleaned result + return result + } + // Fallback: number preceded by space + if let spaceIdx = trimmed.lastIndex(of: " ") { + let potentialNum = String(trimmed[trimmed.index(after: spaceIdx)...]) + if Int(potentialNum) != nil { + return String(trimmed[trimmed.startIndex ..< spaceIdx]) + .trimmingCharacters(in: .whitespaces) + } + } else if Int(trimmed) != nil { + return "" + } + } + } + return self + } +} diff --git a/ios/LibNovel/LibNovel/Models/Models.swift b/ios/LibNovel/LibNovel/Models/Models.swift new file mode 100644 index 0000000..2205330 --- /dev/null +++ b/ios/LibNovel/LibNovel/Models/Models.swift @@ -0,0 +1,395 @@ +import Foundation +import SwiftUI + +// MARK: - Book + +struct Book: Identifiable, Codable, Hashable { + let id: String + let slug: String + let title: String + let author: String + let cover: String + let status: String + let genres: [String] + let summary: String + let totalChapters: Int + let sourceURL: String + let ranking: Int + let metaUpdated: String + + enum CodingKeys: String, CodingKey { + case id, slug, title, author, cover, status, genres, summary + case totalChapters = "total_chapters" + case sourceURL = "source_url" + case ranking + case metaUpdated = "meta_updated" + } + + // PocketBase returns genres as either a JSON string array or a real array + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + slug = try container.decode(String.self, forKey: .slug) + title = try container.decode(String.self, forKey: .title) + author = try container.decode(String.self, forKey: .author) + cover = try container.decodeIfPresent(String.self, forKey: .cover) ?? "" + status = try container.decodeIfPresent(String.self, forKey: .status) ?? "" + totalChapters = try container.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 0 + sourceURL = try container.decodeIfPresent(String.self, forKey: .sourceURL) ?? "" + ranking = try container.decodeIfPresent(Int.self, forKey: .ranking) ?? 0 + metaUpdated = try container.decodeIfPresent(String.self, forKey: .metaUpdated) ?? "" + summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? "" + + // genres is sometimes a JSON-encoded string, sometimes a real array + if let arr = try? container.decode([String].self, forKey: .genres) { + genres = arr + } else if let str = try? container.decode(String.self, forKey: .genres), + let data = str.data(using: .utf8), + let arr = try? JSONDecoder().decode([String].self, from: data) { + genres = arr + } else { + genres = [] + } + } +} + +// MARK: - ChapterIndex + +struct ChapterIndex: Identifiable, Codable, Hashable { + let id: String + let slug: String + let number: Int + let title: String + let dateLabel: String + + enum CodingKeys: String, CodingKey { + case id, slug, number, title + case dateLabel = "date_label" + } +} + +struct ChapterIndexBrief: Codable, Hashable { + let number: Int + let title: String +} + +// MARK: - User Settings + +struct UserSettings: Codable { + var id: String? + var autoNext: Bool + var voice: String + var speed: Double + + // Server sends/expects camelCase: { autoNext, voice, speed } + // (No CodingKeys needed — Swift synthesises the same names by default) + + static let `default` = UserSettings(id: nil, autoNext: false, voice: "af_bella", speed: 1.0) +} + +// MARK: - Reading Display Settings (local only — stored in UserDefaults) + +enum ReaderTheme: String, CaseIterable, Codable { + case white, sepia, night + + var backgroundColor: Color { + switch self { + case .white: return Color(.sRGB, white: 1.0, opacity: 1) + case .sepia: return Color(red: 0.97, green: 0.93, blue: 0.82) + case .night: return Color(red: 0.10, green: 0.10, blue: 0.12) + } + } + + var textColor: Color { + switch self { + case .white: return Color(.sRGB, white: 0.1, opacity: 1) + case .sepia: return Color(red: 0.25, green: 0.18, blue: 0.08) + case .night: return Color(red: 0.85, green: 0.85, blue: 0.87) + } + } + + var colorScheme: ColorScheme? { + switch self { + case .white: return nil // follows system + case .sepia: return .light + case .night: return .dark + } + } +} + +enum ReaderFont: String, CaseIterable, Codable { + case system = "System" + case georgia = "Georgia" + case newYork = "New York" + + var fontName: String? { + switch self { + case .system: return nil + case .georgia: return "Georgia" + case .newYork: return "NewYorkMedium-Regular" + } + } +} + +struct ReaderSettings: Codable, Equatable { + var fontSize: CGFloat + var lineSpacing: CGFloat + var font: ReaderFont + var theme: ReaderTheme + var scrollMode: Bool + + static let `default` = ReaderSettings( + fontSize: 17, + lineSpacing: 1.7, + font: .system, + theme: .white, + scrollMode: false + ) + + static let userDefaultsKey = "readerSettings" + + static func load() -> ReaderSettings { + guard let data = UserDefaults.standard.data(forKey: userDefaultsKey), + let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data) + else { return .default } + return decoded + } + + func save() { + if let data = try? JSONEncoder().encode(self) { + UserDefaults.standard.set(data, forKey: ReaderSettings.userDefaultsKey) + } + } +} + +// MARK: - User + +struct AppUser: Codable, Identifiable { + let id: String + let username: String + let role: String + let created: String + let avatarURL: String? + + var isAdmin: Bool { role == "admin" } + + enum CodingKeys: String, CodingKey { + case id, username, role, created + case avatarURL = "avatar_url" + } + + init(id: String, username: String, role: String, created: String, avatarURL: String?) { + self.id = id + self.username = username + self.role = role + self.created = created + self.avatarURL = avatarURL + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + username = try c.decode(String.self, forKey: .username) + role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user" + created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" + avatarURL = try c.decodeIfPresent(String.self, forKey: .avatarURL) + } +} + +// MARK: - Ranking + +struct RankingItem: Codable, Identifiable { + var id: String { slug } + let rank: Int + let slug: String + let title: String + let author: String + let cover: String + let status: String + let genres: [String] + let sourceURL: String + + enum CodingKeys: String, CodingKey { + case rank, slug, title, author, cover, status, genres + case sourceURL = "source_url" + } +} + +// MARK: - Home + +struct ContinueReadingItem: Identifiable { + var id: String { book.id } + let book: Book + let chapter: Int +} + +struct HomeStats: Codable { + let totalBooks: Int + let totalChapters: Int + let booksInProgress: Int +} + +// MARK: - Session + +struct UserSession: Codable, Identifiable { + let id: String + let userAgent: String + let ip: String + let createdAt: String + let lastSeen: String + var isCurrent: Bool + + enum CodingKeys: String, CodingKey { + case id + case userAgent = "user_agent" + case ip + case createdAt = "created_at" + case lastSeen = "last_seen" + case isCurrent = "is_current" + } +} + +struct PreviewChapter: Codable, Identifiable { + var id: Int { number } + let number: Int + let title: String + let url: String +} + +struct BookBrief: Codable { + let slug: String + let title: String + let cover: String +} + +// MARK: - Comments + +struct BookComment: Identifiable, Codable, Hashable { + let id: String + let slug: String + let userId: String + let username: String + let body: String + var upvotes: Int + var downvotes: Int + let created: String + let parentId: String // empty = top-level; non-empty = reply + var replies: [BookComment]? // populated client-side from the API response + + enum CodingKeys: String, CodingKey { + case id, slug, username, body, upvotes, downvotes, created, replies + case userId = "user_id" + case parentId = "parent_id" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? "" + userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? "" + username = try c.decodeIfPresent(String.self, forKey: .username) ?? "" + body = try c.decodeIfPresent(String.self, forKey: .body) ?? "" + upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0 + downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0 + created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" + parentId = try c.decodeIfPresent(String.self, forKey: .parentId) ?? "" + replies = try c.decodeIfPresent([BookComment].self, forKey: .replies) + } +} + +struct CommentsResponse: Decodable { + let comments: [BookComment] + let myVotes: [String: String] + let avatarUrls: [String: String] + + enum CodingKeys: String, CodingKey { + case comments + case myVotes = "myVotes" + case avatarUrls = "avatarUrls" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + comments = try c.decode([BookComment].self, forKey: .comments) + myVotes = try c.decodeIfPresent([String: String].self, forKey: .myVotes) ?? [:] + avatarUrls = try c.decodeIfPresent([String: String].self, forKey: .avatarUrls) ?? [:] + } +} + +// MARK: - User Profile (public) + +struct PublicUserProfile: Decodable, Identifiable { + let id: String + let username: String + let avatarUrl: String? + let created: String + let followerCount: Int + let followingCount: Int + let isSubscribed: Bool + let isSelf: Bool + + enum CodingKeys: String, CodingKey { + case id, username, created + case avatarUrl = "avatarUrl" + case followerCount = "followerCount" + case followingCount = "followingCount" + case isSubscribed = "isSubscribed" + case isSelf = "isSelf" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + username = try c.decode(String.self, forKey: .username) + avatarUrl = try c.decodeIfPresent(String.self, forKey: .avatarUrl) + created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" + followerCount = try c.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0 + followingCount = try c.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0 + isSubscribed = try c.decodeIfPresent(Bool.self, forKey: .isSubscribed) ?? false + isSelf = try c.decodeIfPresent(Bool.self, forKey: .isSelf) ?? false + } +} + +// MARK: - Subscription Feed + +struct SubscriptionFeedItem: Identifiable, Decodable { + var id: String { book.id + readerUsername } + let book: Book + let readerUsername: String + + enum CodingKeys: String, CodingKey { + case book + case readerUsername = "readerUsername" + } +} + +// MARK: - Public User Library + +struct PublicLibraryItem: Decodable, Identifiable { + var id: String { book.id } + let book: Book + let lastChapter: Int? + let saved: Bool + + enum CodingKeys: String, CodingKey { + case book + case lastChapter = "last_chapter" + case saved + } +} + +struct PublicUserLibraryResponse: Decodable { + let currentlyReading: [PublicLibraryItem] + let library: [PublicLibraryItem] + + enum CodingKeys: String, CodingKey { + case currentlyReading = "currently_reading" + case library + } +} + +// MARK: - Audio + +enum NextPrefetchStatus { + case none, prefetching, prefetched, failed +} diff --git a/ios/LibNovel/LibNovel/Networking/APIClient.swift b/ios/LibNovel/LibNovel/Networking/APIClient.swift new file mode 100644 index 0000000..58956dc --- /dev/null +++ b/ios/LibNovel/LibNovel/Networking/APIClient.swift @@ -0,0 +1,580 @@ +import Foundation + +// MARK: - API Client +// Communicates with the SvelteKit UI server (not directly with the Go scraper). +// The SvelteKit layer handles auth, PocketBase queries, and MinIO presigning. +// For the iOS app we talk to the same /api/* endpoints the web UI uses, +// so we reuse the exact same HMAC-cookie auth flow. + +actor APIClient { + static let shared = APIClient() + + var baseURL: URL + private var authCookie: String? // raw "libnovel_auth=" header value + + // URLSession with persistent cookie storage + private let session: URLSession = { + let config = URLSessionConfiguration.default + config.httpCookieAcceptPolicy = .always + config.httpShouldSetCookies = true + config.httpCookieStorage = HTTPCookieStorage.shared + return URLSession(configuration: config) + }() + + private init() { + // Default: point at the UI server. Override via Settings bundle or compile flag. + let urlString = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String + ?? "https://v2.libnovel.kalekber.cc" + baseURL = URL(string: urlString)! + } + + // MARK: - Auth cookie management + + func setAuthCookie(_ value: String?) { + authCookie = value + if let value { + // Also inject into shared cookie storage so redirects carry the cookie + let cookieProps: [HTTPCookiePropertyKey: Any] = [ + .name: "libnovel_auth", + .value: value, + .domain: baseURL.host ?? "localhost", + .path: "/" + ] + if let cookie = HTTPCookie(properties: cookieProps) { + HTTPCookieStorage.shared.setCookie(cookie) + } + } else { + // Clear + let cookieStorage = HTTPCookieStorage.shared + cookieStorage.cookies(for: baseURL)?.forEach { cookieStorage.deleteCookie($0) } + } + } + + // MARK: - Low-level request builder + + private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest { + // Build URL by appending the path string directly to the base URL string. + // appendingPathComponent() percent-encodes slashes, which breaks multi-segment + // paths like /api/chapter/slug/1. URL(string:) preserves slashes correctly. + let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + + "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let url = URL(string: urlString) else { + throw APIError.invalidResponse + } + var req = URLRequest(url: url) + req.httpMethod = method + req.setValue("application/json", forHTTPHeaderField: "Accept") + if let body { + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONEncoder().encode(body) + } + return req + } + + // MARK: - Generic fetch + + func fetch(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> T { + let req = try makeRequest(path, method: method, body: body) + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + let rawBody = String(data: data, encoding: .utf8) ?? "" + guard (200..<300).contains(http.statusCode) else { + throw APIError.httpError(http.statusCode, rawBody) + } + do { + return try JSONDecoder.iso8601.decode(T.self, from: data) + } catch { + throw APIError.decodingError(error) + } + } + + /// Like `fetch` but discards the response body — use for endpoints that return 204 No Content. + func fetchVoid(_ path: String, method: String = "GET", body: Encodable? = nil) async throws { + let req = try makeRequest(path, method: method, body: body) + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + guard (200..<300).contains(http.statusCode) else { + let rawBody = String(data: data, encoding: .utf8) ?? "" + throw APIError.httpError(http.statusCode, rawBody) + } + } + + // MARK: - Auth + + struct LoginRequest: Encodable { + let username: String + let password: String + } + + struct LoginResponse: Decodable { + let token: String + let user: AppUser + } + + func login(username: String, password: String) async throws -> LoginResponse { + try await fetch("/api/auth/login", method: "POST", + body: LoginRequest(username: username, password: password)) + } + + func register(username: String, password: String) async throws -> LoginResponse { + try await fetch("/api/auth/register", method: "POST", + body: LoginRequest(username: username, password: password)) + } + + func logout() async throws { + let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST") + setAuthCookie(nil) + } + + // MARK: - Home + + func homeData() async throws -> HomeDataResponse { + try await fetch("/api/home") + } + + // MARK: - Library + + func library() async throws -> [LibraryItem] { + try await fetch("/api/library") + } + + func saveBook(slug: String) async throws { + let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "POST") + } + + func unsaveBook(slug: String) async throws { + let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "DELETE") + } + + // MARK: - Book Detail + + func bookDetail(slug: String) async throws -> BookDetailResponse { + try await fetch("/api/book/\(slug)") + } + + // MARK: - Chapter + + func chapterContent(slug: String, chapter: Int) async throws -> ChapterResponse { + try await fetch("/api/chapter/\(slug)/\(chapter)") + } + + // MARK: - Browse + + func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse { + let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)" + return try await fetch("/api/browse-page\(query)") + } + + func search(query: String) async throws -> SearchResponse { + let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query + return try await fetch("/api/search?q=\(encoded)") + } + + func ranking() async throws -> [RankingItem] { + try await fetch("/api/ranking") + } + + // MARK: - Progress + + func progress() async throws -> [ProgressEntry] { + try await fetch("/api/progress") + } + + func setProgress(slug: String, chapter: Int) async throws { + struct Body: Encodable { let chapter: Int } + let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter)) + } + + func deleteProgress(slug: String) async throws { + let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "DELETE") + } + + func audioTime(slug: String, chapter: Int) async throws -> Double? { + struct Response: Decodable { let audioTime: Double?; enum CodingKeys: String, CodingKey { case audioTime = "audio_time" } } + let r: Response = try await fetch("/api/progress/audio-time?slug=\(slug)&chapter=\(chapter)") + return r.audioTime + } + + func setAudioTime(slug: String, chapter: Int, time: Double) async throws { + struct Body: Encodable { let slug: String; let chapter: Int; let audioTime: Double; enum CodingKeys: String, CodingKey { case slug, chapter; case audioTime = "audio_time" } } + let _: EmptyResponse = try await fetch("/api/progress/audio-time", method: "PATCH", body: Body(slug: slug, chapter: chapter, audioTime: time)) + } + + // MARK: - Audio + + func triggerAudio(slug: String, chapter: Int, voice: String, speed: Double) async throws -> AudioTriggerResponse { + struct Body: Encodable { let voice: String; let speed: Double } + return try await fetch("/api/audio/\(slug)/\(chapter)", method: "POST", body: Body(voice: voice, speed: speed)) + } + + /// Poll GET /api/audio/status/{slug}/{n}?voice=... until the job is done or failed. + /// Returns the presigned/proxy URL on success, throws on failure or cancellation. + func pollAudioStatus(slug: String, chapter: Int, voice: String) async throws -> String { + let path = "/api/audio/status/\(slug)/\(chapter)?voice=\(voice)" + struct StatusResponse: Decodable { + let status: String + let url: String? + let error: String? + } + while true { + try Task.checkCancellation() + let r: StatusResponse = try await fetch(path) + switch r.status { + case "done": + guard let url = r.url, !url.isEmpty else { + throw URLError(.badServerResponse) + } + return url + case "failed": + throw NSError( + domain: "AudioGeneration", + code: 0, + userInfo: [NSLocalizedDescriptionKey: r.error ?? "Audio generation failed"] + ) + default: + // pending / generating / idle — keep polling + try await Task.sleep(nanoseconds: 2_000_000_000) // 2 s + } + } + } + + func presignAudio(slug: String, chapter: Int, voice: String) async throws -> String { + struct Response: Decodable { let url: String } + let r: Response = try await fetch("/api/presign/audio?slug=\(slug)&chapter=\(chapter)&voice=\(voice)") + return r.url + } + + func presignVoiceSample(voice: String) async throws -> String { + struct Response: Decodable { let url: String } + let r: Response = try await fetch("/api/presign/voice-sample?voice=\(voice)") + return r.url + } + + func voices() async throws -> [String] { + struct Response: Decodable { let voices: [String] } + let r: Response = try await fetch("/api/voices") + return r.voices + } + + // MARK: - Settings + + func settings() async throws -> UserSettings { + try await fetch("/api/settings") + } + + func updateSettings(_ settings: UserSettings) async throws { + let _: EmptyResponse = try await fetch("/api/settings", method: "PUT", body: settings) + } + + // MARK: - Sessions + + func sessions() async throws -> [UserSession] { + struct Response: Decodable { let sessions: [UserSession] } + let r: Response = try await fetch("/api/sessions") + return r.sessions + } + + func revokeSession(id: String) async throws { + let _: EmptyResponse = try await fetch("/api/sessions/\(id)", method: "DELETE") + } + + // MARK: - Avatar + + struct AvatarPresignResponse: Decodable { + let uploadURL: String + let key: String + enum CodingKeys: String, CodingKey { case uploadURL = "upload_url"; case key } + } + + struct AvatarResponse: Decodable { + let avatarURL: String? + enum CodingKeys: String, CodingKey { case avatarURL = "avatar_url" } + } + + /// Upload a profile avatar using a two-step presigned PUT flow: + /// 1. POST /api/profile/avatar → get a presigned PUT URL + object key + /// 2. PUT image bytes directly to MinIO via the presigned URL + /// 3. PATCH /api/profile/avatar with the key to record it in PocketBase + /// Returns the presigned GET URL for the uploaded avatar. + func uploadAvatar(_ imageData: Data, mimeType: String = "image/jpeg") async throws -> String? { + // Step 1: request a presigned PUT URL from the SvelteKit server + let presign: AvatarPresignResponse = try await fetch( + "/api/profile/avatar", + method: "POST", + body: ["mime_type": mimeType] + ) + + // Step 2: PUT the image bytes directly to MinIO + guard let putURL = URL(string: presign.uploadURL) else { throw APIError.invalidResponse } + var putReq = URLRequest(url: putURL) + putReq.httpMethod = "PUT" + putReq.setValue(mimeType, forHTTPHeaderField: "Content-Type") + putReq.httpBody = imageData + + let (_, putResp) = try await session.data(for: putReq) + guard let putHttp = putResp as? HTTPURLResponse, + (200..<300).contains(putHttp.statusCode) else { + let code = (putResp as? HTTPURLResponse)?.statusCode ?? 0 + throw APIError.httpError(code, "MinIO PUT failed") + } + + // Step 3: record the key in PocketBase and get back a presigned GET URL + let result: AvatarResponse = try await fetch( + "/api/profile/avatar", + method: "PATCH", + body: ["key": presign.key] + ) + return result.avatarURL + } + + /// Fetches a fresh presigned GET URL for the current user's avatar. + /// Returns nil if the user has no avatar set. + /// Used on cold launch / session restore to convert the stored raw key into a viewable URL. + func fetchAvatarPresignedURL() async throws -> String? { + let result: AvatarResponse = try await fetch("/api/profile/avatar") + return result.avatarURL + } + + // MARK: - User Profiles & Subscriptions + + func fetchUserProfile(username: String) async throws -> PublicUserProfile { + try await fetch("/api/users/\(username)") + } + + @discardableResult + func subscribeUser(username: String) async throws -> Bool { + struct Response: Decodable { let subscribed: Bool } + let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "POST") + return r.subscribed + } + + @discardableResult + func unsubscribeUser(username: String) async throws -> Bool { + struct Response: Decodable { let subscribed: Bool } + let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "DELETE") + return r.subscribed + } + + func fetchUserLibrary(username: String) async throws -> PublicUserLibraryResponse { + try await fetch("/api/users/\(username)/library") + } + + // MARK: - Comments + + func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse { + try await fetch("/api/comments/\(slug)?sort=\(sort)") + } + + struct PostCommentBody: Encodable { + let body: String + let parent_id: String? + } + + func postComment(slug: String, body: String, parentId: String? = nil) async throws -> BookComment { + try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body, parent_id: parentId)) + } + + struct VoteBody: Encodable { let vote: String } + + /// Cast, change, or toggle-off a vote on a comment. + /// Returns the updated BookComment (with refreshed upvotes/downvotes counts). + func voteComment(commentId: String, vote: String) async throws -> BookComment { + try await fetch("/api/comment/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote)) + } + + /// Delete a comment (and its replies) by ID. Only the owner can delete. + func deleteComment(commentId: String) async throws { + try await fetchVoid("/api/comment/\(commentId)", method: "DELETE") + } +} + +// MARK: - Response types + +struct HomeDataResponse: Decodable { + struct ContinueItem: Decodable { + let book: Book + let chapter: Int + } + let continueReading: [ContinueItem] + let recentlyUpdated: [Book] + let stats: HomeStats + let subscriptionFeed: [SubscriptionFeedItem] + + enum CodingKeys: String, CodingKey { + case continueReading = "continue_reading" + case recentlyUpdated = "recently_updated" + case stats + case subscriptionFeed = "subscription_feed" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + continueReading = try c.decodeIfPresent([ContinueItem].self, forKey: .continueReading) ?? [] + recentlyUpdated = try c.decodeIfPresent([Book].self, forKey: .recentlyUpdated) ?? [] + stats = try c.decode(HomeStats.self, forKey: .stats) + subscriptionFeed = try c.decodeIfPresent([SubscriptionFeedItem].self, forKey: .subscriptionFeed) ?? [] + } +} + +struct LibraryItem: Decodable, Identifiable { + var id: String { book.id } + let book: Book + let savedAt: String + let lastChapter: Int? + + enum CodingKeys: String, CodingKey { + case book + case savedAt = "saved_at" + case lastChapter = "last_chapter" + } +} + +struct BookDetailResponse: Decodable { + let book: Book + let chapters: [ChapterIndex] + let previewChapters: [PreviewChapter]? + let inLib: Bool + let saved: Bool + let lastChapter: Int? + + enum CodingKeys: String, CodingKey { + case book, chapters + case previewChapters = "preview_chapters" + case inLib = "in_lib" + case saved + case lastChapter = "last_chapter" + } +} + +struct ChapterResponse: Decodable { + let book: BookBrief + let chapter: ChapterIndex + let html: String + let voices: [String] + let prev: Int? + let next: Int? + let chapters: [ChapterIndexBrief] + let isPreview: Bool + + enum CodingKeys: String, CodingKey { + case book, chapter, html, voices, prev, next, chapters + case isPreview = "is_preview" + } +} + +struct BrowseResponse: Decodable { + let novels: [BrowseNovel] + let page: Int + let hasNext: Bool +} + +struct BrowseNovel: Decodable, Identifiable, Hashable { + var id: String { slug.isEmpty ? url : slug } + let slug: String + let title: String + let cover: String + let rank: String + let rating: String + let chapters: String + let url: String + let author: String + let status: String + let genres: [String] + + enum CodingKeys: String, CodingKey { + case slug, title, cover, rank, rating, chapters, url, author, status, genres + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? "" + title = try c.decode(String.self, forKey: .title) + cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? "" + rank = try c.decodeIfPresent(String.self, forKey: .rank) ?? "" + rating = try c.decodeIfPresent(String.self, forKey: .rating) ?? "" + chapters = try c.decodeIfPresent(String.self, forKey: .chapters) ?? "" + url = try c.decodeIfPresent(String.self, forKey: .url) ?? "" + author = try c.decodeIfPresent(String.self, forKey: .author) ?? "" + status = try c.decodeIfPresent(String.self, forKey: .status) ?? "" + genres = try c.decodeIfPresent([String].self, forKey: .genres) ?? [] + } +} + +struct SearchResponse: Decodable { + let results: [BrowseNovel] + let localCount: Int + let remoteCount: Int + + enum CodingKeys: String, CodingKey { + case results + case localCount = "local_count" + case remoteCount = "remote_count" + } +} + +/// Returned by POST /api/audio/{slug}/{n}. +/// - 202 Accepted: job enqueued → poll via pollAudioStatus() +/// - 200 OK: audio already cached → url is ready to play +struct AudioTriggerResponse: Decodable { + let jobId: String? // present on 202 + let status: String? // present on 202: "pending" | "generating" + let url: String? // present on 200: proxy URL ready to play + let filename: String? // present on 200 + + enum CodingKeys: String, CodingKey { + case jobId = "job_id" + case status, url, filename + } + + /// True when the server accepted the request and created an async job. + var isAsync: Bool { jobId != nil } +} + +struct ProgressEntry: Decodable, Identifiable { + var id: String { slug } + let slug: String + let chapter: Int + let audioTime: Double? + let updated: String + + enum CodingKeys: String, CodingKey { + case slug, chapter, updated + case audioTime = "audio_time" + } +} + +struct EmptyResponse: Decodable {} + +// MARK: - API Error + +enum APIError: LocalizedError { + case invalidResponse + case httpError(Int, String) + case decodingError(Error) + case unauthorized + case networkError(Error) + + var errorDescription: String? { + switch self { + case .invalidResponse: return "Invalid server response" + case .httpError(let code, let msg): return "HTTP \(code): \(msg)" + case .decodingError(let e): return "Decode error: \(e.localizedDescription)" + case .unauthorized: return "Not authenticated" + case .networkError(let e): return e.localizedDescription + } + } +} + +// MARK: - JSONDecoder helper + +extension JSONDecoder { + static let iso8601: JSONDecoder = { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + }() +} diff --git a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..e2be29f --- /dev/null +++ b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,12 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { "alpha": "1.000", "blue": "0.040", "green": "0.620", "red": "0.960" } + }, + "idiom": "universal" + } + ], + "info": { "author": "xcode", "version": 1 } +} diff --git a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..27a4f38 --- /dev/null +++ b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000..820557a Binary files /dev/null and b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/Contents.json b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..319a86b --- /dev/null +++ b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,3 @@ +{ + "info": { "author": "xcode", "version": 1 } +} diff --git a/ios/LibNovel/LibNovel/Resources/Info.plist b/ios/LibNovel/LibNovel/Resources/Info.plist new file mode 100644 index 0000000..aa594f9 --- /dev/null +++ b/ios/LibNovel/LibNovel/Resources/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDisplayName + LibNovel + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + LibNovel + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + 1000 + LIBNOVEL_BASE_URL + $(LIBNOVEL_BASE_URL) + LSRequiresIPhoneOS + + UIBackgroundModes + + audio + fetch + processing + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/LibNovel/LibNovel/Services/AudioDownloadService.swift b/ios/LibNovel/LibNovel/Services/AudioDownloadService.swift new file mode 100644 index 0000000..c98f25b --- /dev/null +++ b/ios/LibNovel/LibNovel/Services/AudioDownloadService.swift @@ -0,0 +1,318 @@ +import Foundation +import Combine + +// MARK: - AudioDownloadService +// Manages offline TTS audio downloads with progress tracking and persistent storage. +// Downloads are saved to the app's Documents directory, organized by slug/chapter/voice. + +@MainActor +final class AudioDownloadService: NSObject, ObservableObject { + static let shared = AudioDownloadService() + + // MARK: - Published State + + @Published var downloads: [String: DownloadProgress] = [:] // key: "slug::chapter::voice" + @Published var downloadedChapters: Set = [] // key: "slug::chapter::voice" + + // MARK: - Private + + private var session: URLSession! + private var activeTasks: [String: URLSessionDownloadTask] = [:] + private let fileManager = FileManager.default + private let metadataKey = "downloadedChaptersMetadata" + + // MARK: - Init + + private override init() { + super.init() + let config = URLSessionConfiguration.background(withIdentifier: "cc.kalekber.libnovel.audio-downloads") + config.isDiscretionary = false + config.sessionSendsLaunchEvents = true + session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + loadMetadata() + } + + // MARK: - Public API + + /// Check if a chapter's audio is downloaded offline + func isDownloaded(slug: String, chapter: Int, voice: String) -> Bool { + let key = makeKey(slug: slug, chapter: chapter, voice: voice) + return downloadedChapters.contains(key) + } + + /// Get the local file URL for a downloaded chapter (nil if not downloaded) + func localURL(slug: String, chapter: Int, voice: String) -> URL? { + guard isDownloaded(slug: slug, chapter: chapter, voice: voice) else { return nil } + return audioFileURL(slug: slug, chapter: chapter, voice: voice) + } + + /// Start downloading a chapter's audio + func download(slug: String, chapter: Int, voice: String) async throws { + let key = makeKey(slug: slug, chapter: chapter, voice: voice) + + print("📥 AudioDownload: Starting download - slug: \(slug), chapter: \(chapter), voice: \(voice)") + + // Already downloaded or in progress + if downloadedChapters.contains(key) { + print("⚠️ AudioDownload: Already downloaded - key: \(key)") + return + } + if activeTasks[key] != nil { + print("⚠️ AudioDownload: Already in progress - key: \(key)") + return + } + + // Get presigned URL from API + print("🔗 AudioDownload: Fetching presigned URL...") + let urlString = try await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) + guard let url = URL(string: urlString) else { + print("❌ AudioDownload: Invalid URL - \(urlString)") + throw URLError(.badURL) + } + + print("🔗 AudioDownload: Presigned URL obtained: \(url.absoluteString)") + + // Create download task + let task = session.downloadTask(with: url) + task.taskDescription = key // Use taskDescription to identify the download + activeTasks[key] = task + + // Initialize progress tracking + downloads[key] = DownloadProgress( + slug: slug, + chapter: chapter, + voice: voice, + progress: 0, + totalBytes: 0, + downloadedBytes: 0, + status: .downloading + ) + + print("🚀 AudioDownload: Starting download task - key: \(key)") + task.resume() + } + + /// Cancel an ongoing download + func cancelDownload(slug: String, chapter: Int, voice: String) { + let key = makeKey(slug: slug, chapter: chapter, voice: voice) + activeTasks[key]?.cancel() + activeTasks.removeValue(forKey: key) + downloads.removeValue(forKey: key) + } + + /// Delete a downloaded chapter + func deleteDownload(slug: String, chapter: Int, voice: String) throws { + let key = makeKey(slug: slug, chapter: chapter, voice: voice) + let fileURL = audioFileURL(slug: slug, chapter: chapter, voice: voice) + + if fileManager.fileExists(atPath: fileURL.path) { + try fileManager.removeItem(at: fileURL) + } + + downloadedChapters.remove(key) + downloads.removeValue(forKey: key) + saveMetadata() + } + + /// Get total storage used by downloads (in bytes) + func getTotalStorageUsed() -> Int64 { + guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + return 0 + } + + let audioDir = documentsURL.appendingPathComponent("audio") + guard let enumerator = fileManager.enumerator(at: audioDir, includingPropertiesForKeys: [.fileSizeKey]) else { + return 0 + } + + var totalSize: Int64 = 0 + for case let fileURL as URL in enumerator { + if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { + totalSize += Int64(fileSize) + } + } + return totalSize + } + + /// Delete all downloads + func deleteAllDownloads() throws { + guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + return + } + + let audioDir = documentsURL.appendingPathComponent("audio") + if fileManager.fileExists(atPath: audioDir.path) { + try fileManager.removeItem(at: audioDir) + } + + downloadedChapters.removeAll() + downloads.removeAll() + activeTasks.values.forEach { $0.cancel() } + activeTasks.removeAll() + saveMetadata() + } + + /// Get list of all book slugs that have offline downloads + func getOfflineBookSlugs() -> [String] { + let slugs = downloadedChapters.compactMap { key -> String? in + let components = key.split(separator: "::") + guard components.count == 3 else { return nil } + return String(components[0]) + } + return Array(Set(slugs)).sorted() + } + + /// Get count of downloaded chapters for a specific book + func getDownloadedChapterCount(for slug: String) -> Int { + return downloadedChapters.filter { key in + let components = key.split(separator: "::") + guard components.count == 3 else { return false } + return String(components[0]) == slug + }.count + } + + // MARK: - Private Helpers + + /// Build the canonical download key used for both in-memory tracking and UserDefaults. + /// Uses `::` as separator so slugs that contain `-` are unambiguous. + func makeKey(slug: String, chapter: Int, voice: String) -> String { + "\(slug)::\(chapter)::\(voice)" + } + + nonisolated private func audioFileURL(slug: String, chapter: Int, voice: String) -> URL { + guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + fatalError("Could not access documents directory") + } + + return documentsURL + .appendingPathComponent("audio") + .appendingPathComponent(slug) + .appendingPathComponent("\(chapter)-\(voice).mp3") + } + + private func loadMetadata() { + if let data = UserDefaults.standard.data(forKey: metadataKey), + let decoded = try? JSONDecoder().decode(Set.self, from: data) { + downloadedChapters = decoded + } + } + + private func saveMetadata() { + if let encoded = try? JSONEncoder().encode(downloadedChapters) { + UserDefaults.standard.set(encoded, forKey: metadataKey) + } + } +} + +// MARK: - URLSessionDownloadDelegate + +extension AudioDownloadService: URLSessionDownloadDelegate { + nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + guard let key = downloadTask.taskDescription else { + print("⚠️ AudioDownload: No task description") + return + } + + print("✅ AudioDownload: Finished downloading - key: \(key)") + + let components = key.split(separator: "::") + guard components.count == 3, + let chapter = Int(components[1]) else { + print("⚠️ AudioDownload: Invalid key format: \(key)") + return + } + + let slug = String(components[0]) + let voice = String(components[2]) + + let destinationURL = audioFileURL(slug: slug, chapter: chapter, voice: voice) + + print("📁 AudioDownload: Moving from \(location.path) to \(destinationURL.path)") + + do { + // Create directory if needed + let directory = destinationURL.deletingLastPathComponent() + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + + // Move file from temp location to permanent storage + if fileManager.fileExists(atPath: destinationURL.path) { + print("📁 AudioDownload: Removing existing file at destination") + try fileManager.removeItem(at: destinationURL) + } + try fileManager.moveItem(at: location, to: destinationURL) + + print("✅ AudioDownload: File moved successfully") + + Task { @MainActor in + print("✅ AudioDownload: Marking as completed - key: \(key)") + self.downloadedChapters.insert(key) + self.downloads.removeValue(forKey: key) // Remove from active downloads + self.activeTasks.removeValue(forKey: key) + self.saveMetadata() + print("✅ AudioDownload: Metadata saved, downloadedChapters count: \(self.downloadedChapters.count)") + } + } catch { + print("❌ AudioDownload: Failed to move file - \(error.localizedDescription)") + Task { @MainActor in + self.downloads[key]?.status = .failed(error.localizedDescription) + self.activeTasks.removeValue(forKey: key) + } + } + } + + nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + guard let key = downloadTask.taskDescription else { return } + + let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0 + + if Int(progress * 100) % 10 == 0 { // Log every 10% + print("📊 AudioDownload: Progress for \(key): \(Int(progress * 100))% (\(totalBytesWritten)/\(totalBytesExpectedToWrite) bytes)") + } + + Task { @MainActor in + if var progressData = self.downloads[key] { + progressData.downloadedBytes = totalBytesWritten + progressData.totalBytes = totalBytesExpectedToWrite + progressData.progress = progress + self.downloads[key] = progressData + } + } + } + + nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let key = task.taskDescription else { return } + + if let error = error { + let nsError = error as NSError + if nsError.code != NSURLErrorCancelled { + print("❌ AudioDownload: Task completed with error - key: \(key), error: \(error.localizedDescription)") + Task { @MainActor in + self.downloads[key]?.status = .failed(error.localizedDescription) + self.activeTasks.removeValue(forKey: key) + } + } else { + print("⚠️ AudioDownload: Task cancelled - key: \(key)") + } + } else { + print("✅ AudioDownload: Task completed without error - key: \(key)") + } + } +} + +// MARK: - Supporting Types + +struct DownloadProgress: Equatable { + let slug: String + let chapter: Int + let voice: String + var progress: Double + var totalBytes: Int64 + var downloadedBytes: Int64 + var status: DownloadStatus +} + +enum DownloadStatus: Equatable { + case downloading + case completed + case failed(String) +} diff --git a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift new file mode 100644 index 0000000..d30fd4c --- /dev/null +++ b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift @@ -0,0 +1,627 @@ +import Foundation +import AVFoundation +import MediaPlayer +import Combine +import Kingfisher + +// MARK: - PlaybackProgress +// Isolated ObservableObject for high-frequency playback state (currentTime, +// duration, isPlaying). Keeping these separate from AudioPlayerService means +// the 0.5-second time-observer ticks only invalidate views that explicitly +// observe PlaybackProgress — menus and other stable UI are unaffected. + +@MainActor +final class PlaybackProgress: ObservableObject { + @Published var currentTime: Double = 0 + @Published var duration: Double = 0 + @Published var isPlaying: Bool = false +} + +// MARK: - AudioPlayerService +// Central singleton that owns AVPlayer, drives audio state, handles lock-screen +// controls (NowPlayingInfoCenter + MPRemoteCommandCenter), and pre-fetches the +// next chapter audio. + +@MainActor +final class AudioPlayerService: ObservableObject { + + // MARK: - Published state + + @Published var slug: String = "" + @Published var chapter: Int = 0 + @Published var chapterTitle: String = "" + @Published var bookTitle: String = "" + @Published var coverURL: String = "" + @Published var voice: String = "af_bella" + @Published var speed: Double = 1.0 + @Published var chapters: [ChapterIndexBrief] = [] + + @Published var status: AudioPlayerStatus = .idle + @Published var audioURL: String = "" + @Published var errorMessage: String = "" + @Published var generationProgress: Double = 0 + + /// High-frequency playback state (currentTime / duration / isPlaying). + /// Views that only need the seek bar or play-pause button should observe + /// this directly so they don't trigger re-renders of menu-bearing parents. + let progress = PlaybackProgress() + + // Convenience forwarders so non-view call sites keep compiling unchanged. + var currentTime: Double { + get { progress.currentTime } + set { progress.currentTime = newValue } + } + var duration: Double { + get { progress.duration } + set { progress.duration = newValue } + } + var isPlaying: Bool { + get { progress.isPlaying } + set { progress.isPlaying = newValue } + } + + @Published var autoNext: Bool = false + @Published var nextChapter: Int? = nil + @Published var prevChapter: Int? = nil + + @Published var sleepTimer: SleepTimerOption? = nil + /// Human-readable countdown string shown in the full player near the moon button. + /// e.g. "38:12" for minute-based, "2 ch left" for chapter-based, "" when off. + @Published var sleepTimerRemainingText: String = "" + + @Published var nextPrefetchStatus: NextPrefetchStatus = .none + @Published var nextAudioURL: String = "" + @Published var nextPrefetchedChapter: Int? = nil + + var isActive: Bool { + switch status { + case .idle: return false + default: return true + } + } + + // MARK: - Private + + private var player: AVPlayer? + private var playerItem: AVPlayerItem? + private var timeObserver: Any? + private var statusObserver: AnyCancellable? + private var durationObserver: AnyCancellable? + private var finishObserver: AnyCancellable? + private var generationTask: Task? + private var prefetchTask: Task? + + // Cached cover image — downloaded once per chapter load, reused on every + // updateNowPlaying() call so we don't re-download on every play/pause/seek. + private var cachedCoverArtwork: MPMediaItemArtwork? + private var cachedCoverURL: String = "" + + // Sleep timer tracking + private var sleepTimerTask: Task? + private var sleepTimerStartChapter: Int = 0 + /// Absolute deadline for minute-based timers (nil when not active or chapter-based). + private var sleepTimerDeadline: Date? = nil + /// 1-second tick task that keeps sleepTimerRemainingText up-to-date. + private var sleepTimerCountdownTask: Task? = nil + + // MARK: - Init + + init() { + configureAudioSession() + setupRemoteCommandCenter() + } + + // MARK: - Public API + + /// Load audio for a specific chapter. Triggers TTS generation if not cached. + func load(slug: String, chapter: Int, chapterTitle: String, + bookTitle: String, coverURL: String, voice: String, speed: Double, + chapters: [ChapterIndexBrief], nextChapter: Int?, prevChapter: Int?) { + generationTask?.cancel() + prefetchTask?.cancel() + stop() + + self.slug = slug + self.chapter = chapter + self.chapterTitle = chapterTitle + self.bookTitle = bookTitle + self.coverURL = coverURL + self.voice = voice + self.speed = speed + self.chapters = chapters + self.nextChapter = nextChapter + self.prevChapter = prevChapter + self.nextPrefetchStatus = .none + self.nextAudioURL = "" + self.nextPrefetchedChapter = nil + + // Reset sleep timer start chapter if it's a chapter-based timer + if case .chapters = sleepTimer { + sleepTimerStartChapter = chapter + } + + status = .generating + generationProgress = 0 + + // Invalidate cover cache if the book changed. + if coverURL != cachedCoverURL { + cachedCoverArtwork = nil + cachedCoverURL = coverURL + prefetchCoverArtwork(from: coverURL) + } + + generationTask = Task { await generateAudio() } + } + + func play() { + player?.play() + player?.rate = Float(speed) + isPlaying = true + updateNowPlaying() + } + + func pause() { + player?.pause() + isPlaying = false + updateNowPlaying() + } + + func togglePlayPause() { + isPlaying ? pause() : play() + } + + func seek(to seconds: Double) { + let time = CMTime(seconds: seconds, preferredTimescale: 600) + currentTime = seconds // optimistic UI update + player?.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in + guard let self else { return } + Task { @MainActor in self.updateNowPlaying() } + } + } + + func skip(by seconds: Double) { + seek(to: max(0, min(currentTime + seconds, duration))) + } + + func setSpeed(_ newSpeed: Double) { + speed = newSpeed + if isPlaying { player?.rate = Float(newSpeed) } + updateNowPlaying() + } + + func setSleepTimer(_ option: SleepTimerOption?) { + // Cancel existing timer + countdown + sleepTimerTask?.cancel() + sleepTimerTask = nil + sleepTimerCountdownTask?.cancel() + sleepTimerCountdownTask = nil + sleepTimerDeadline = nil + + sleepTimer = option + + guard let option else { + sleepTimerRemainingText = "" + return + } + + // Start timer based on option + switch option { + case .chapters(let count): + sleepTimerStartChapter = chapter + // Update display immediately; chapter changes are tracked in handlePlaybackFinished. + updateChapterTimerLabel(chaptersRemaining: count) + + case .minutes(let minutes): + let deadline = Date().addingTimeInterval(Double(minutes) * 60) + sleepTimerDeadline = deadline + // Stop playback when the deadline is reached. + sleepTimerTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(minutes) * 60 * 1_000_000_000) + guard let self, !Task.isCancelled else { return } + await MainActor.run { + self.stop() + self.sleepTimer = nil + self.sleepTimerRemainingText = "" + } + } + // 1-second tick to keep the countdown label fresh. + sleepTimerCountdownTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + guard let self, !Task.isCancelled else { return } + await MainActor.run { + guard let deadline = self.sleepTimerDeadline else { return } + let remaining = max(0, deadline.timeIntervalSinceNow) + self.sleepTimerRemainingText = Self.formatCountdown(remaining) + } + } + } + // Set initial label without waiting for the first tick. + sleepTimerRemainingText = Self.formatCountdown(Double(minutes) * 60) + } + } + + private func updateChapterTimerLabel(chaptersRemaining: Int) { + sleepTimerRemainingText = chaptersRemaining == 1 ? "1 ch left" : "\(chaptersRemaining) ch left" + } + + private static func formatCountdown(_ seconds: Double) -> String { + let s = Int(max(0, seconds)) + let m = s / 60 + let sec = s % 60 + return "\(m):\(String(format: "%02d", sec))" + } + + func stop() { + player?.pause() + teardownPlayer() + isPlaying = false + currentTime = 0 + duration = 0 + audioURL = "" + status = .idle + + // Cancel sleep timer + countdown + sleepTimerTask?.cancel() + sleepTimerTask = nil + sleepTimerCountdownTask?.cancel() + sleepTimerCountdownTask = nil + sleepTimerDeadline = nil + sleepTimer = nil + sleepTimerRemainingText = "" + } + + // MARK: - Audio generation + + private func generateAudio() async { + guard !slug.isEmpty, chapter > 0 else { return } + + // Check if audio is downloaded locally first + if let localURL = AudioDownloadService.shared.localURL(slug: slug, chapter: chapter, voice: voice) { + audioURL = localURL.absoluteString + status = .ready + generationProgress = 100 + await playURL(localURL.absoluteString) + await prefetchNext() + return + } + + do { + // Fast path: audio already in MinIO — get a presigned URL and play immediately. + if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) { + audioURL = presignedURL + status = .ready + generationProgress = 100 + await playURL(presignedURL) + await prefetchNext() + return + } + + // Slow path: trigger TTS generation (async — returns 202 immediately). + status = .generating + generationProgress = 10 + let trigger = try await APIClient.shared.triggerAudio(slug: slug, chapter: chapter, voice: voice, speed: speed) + + let playableURL: String + if trigger.isAsync { + // 202 Accepted: poll until done. + generationProgress = 30 + playableURL = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: chapter, voice: voice) + } else { + // 200: already cached URL returned inline. + guard let url = trigger.url, !url.isEmpty else { + throw URLError(.badServerResponse) + } + playableURL = url + } + + audioURL = playableURL + status = .ready + generationProgress = 100 + await playURL(playableURL) + await prefetchNext() + } catch is CancellationError { + // Cancelled — no-op + } catch { + status = .error(error.localizedDescription) + errorMessage = error.localizedDescription + } + } + + // MARK: - Prefetch next chapter + // Always prefetch regardless of autoNext — faster playback when the user + // manually navigates forward. autoNext only controls whether we auto-navigate. + + private func prefetchNext() async { + guard let next = nextChapter, !Task.isCancelled else { return } + nextPrefetchStatus = .prefetching + nextPrefetchedChapter = next + do { + // Fast path: already in MinIO. + if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: next, voice: voice) { + nextAudioURL = presignedURL + nextPrefetchStatus = .prefetched + return + } + // Slow path: trigger generation; poll until done (background — won't block playback). + let trigger = try await APIClient.shared.triggerAudio(slug: slug, chapter: next, voice: voice, speed: speed) + let url: String + if trigger.isAsync { + url = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: next, voice: voice) + } else { + guard let u = trigger.url, !u.isEmpty else { throw URLError(.badServerResponse) } + url = u + } + nextAudioURL = url + nextPrefetchStatus = .prefetched + } catch { + nextPrefetchStatus = .failed + } + } + + // MARK: - AVPlayer management + + private func playURL(_ urlString: String) async { + // Resolve relative paths (e.g. "/api/audio/...") to absolute URLs. + let resolved: URL? + if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") { + resolved = URL(string: urlString) + } else { + resolved = URL(string: urlString, relativeTo: await APIClient.shared.baseURL)?.absoluteURL + } + guard let url = resolved else { return } + teardownPlayer() + let item = AVPlayerItem(url: url) + playerItem = item + player = AVPlayer(playerItem: item) + + // KVO: update duration as soon as asset metadata is loaded. + durationObserver = item.publisher(for: \.duration) + .receive(on: RunLoop.main) + .sink { [weak self] dur in + guard let self else { return } + let secs = dur.seconds + if secs.isFinite && secs > 0 { + self.duration = secs + self.updateNowPlaying() + } + } + + // KVO: set playback rate once the item is ready. + // Do NOT call player?.play() unconditionally — let readyToPlay trigger it + // so we don't race between AVPlayer's internal buffering and our call. + statusObserver = item.publisher(for: \.status) + .receive(on: RunLoop.main) + .sink { [weak self] itemStatus in + guard let self else { return } + if itemStatus == .readyToPlay { + self.player?.rate = Float(self.speed) + self.isPlaying = true + self.updateNowPlaying() + } else if itemStatus == .failed { + self.status = .error(item.error?.localizedDescription ?? "Playback failed") + self.errorMessage = item.error?.localizedDescription ?? "Playback failed" + } + } + + // Periodic time observer for seek bar position. + timeObserver = player?.addPeriodicTimeObserver( + forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), + queue: .main + ) { [weak self] time in + guard let self else { return } + Task { @MainActor in + let secs = time.seconds + if secs.isFinite && secs >= 0 { + self.currentTime = secs + } + } + } + + // Observe when playback ends. + finishObserver = NotificationCenter.default + .publisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: item) + .sink { [weak self] _ in + Task { @MainActor in + self?.handlePlaybackFinished() + } + } + + // Kick off buffering — actual playback starts via statusObserver above. + player?.play() + } + + private func teardownPlayer() { + if let observer = timeObserver { player?.removeTimeObserver(observer) } + timeObserver = nil + statusObserver = nil + durationObserver = nil + finishObserver = nil + player = nil + playerItem = nil + } + + private func handlePlaybackFinished() { + isPlaying = false + + guard let next = nextChapter else { return } + + // Check chapter-based sleep timer + if case .chapters(let count) = sleepTimer { + let chaptersPlayed = chapter - sleepTimerStartChapter + 1 + if chaptersPlayed >= count { + stop() + return + } + // Update the remaining chapters label. + let remaining = count - chaptersPlayed + updateChapterTimerLabel(chaptersRemaining: remaining) + } + + // Always notify the view that the chapter finished (it may update UI). + NotificationCenter.default.post( + name: .audioDidFinishChapter, + object: nil, + userInfo: ["next": next, "autoNext": autoNext] + ) + + // If autoNext is on, load the next chapter internally right away. + // We already have the metadata in `chapters`, so we can reconstruct + // everything without waiting for the view to navigate. + guard autoNext else { return } + + let nextTitle = chapters.first(where: { $0.number == next })?.title ?? "" + let nextNextChapter = chapters.first(where: { $0.number > next })?.number + let nextPrevChapter: Int? = chapter // Current chapter becomes previous for the next one + + // If we already prefetched a URL for the next chapter, skip straight to + // playback and kick off generation in the background for the one after. + if nextPrefetchStatus == .prefetched, !nextAudioURL.isEmpty { + let url = nextAudioURL + + // Advance state before tearing down the current player. + chapter = next + chapterTitle = nextTitle + nextChapter = nextNextChapter + prevChapter = nextPrevChapter + nextPrefetchStatus = .none + nextAudioURL = "" + nextPrefetchedChapter = nil + audioURL = url + status = .ready + generationProgress = 100 + + // Update sleep timer start chapter if using chapter-based timer + if case .chapters = sleepTimer { + sleepTimerStartChapter = next + } + + generationTask = Task { + await playURL(url) + await prefetchNext() + } + } else { + // No prefetch available — do a full load. + load( + slug: slug, + chapter: next, + chapterTitle: nextTitle, + bookTitle: bookTitle, + coverURL: coverURL, + voice: voice, + speed: speed, + chapters: chapters, + nextChapter: nextNextChapter, + prevChapter: nextPrevChapter + ) + } + } + + // MARK: - Cover art prefetch + + private func prefetchCoverArtwork(from urlString: String) { + guard !urlString.isEmpty, let url = URL(string: urlString) else { return } + KingfisherManager.shared.retrieveImage(with: url) { [weak self] result in + guard let self else { return } + if case .success(let value) = result { + let image = value.image + let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + Task { @MainActor in + self.cachedCoverArtwork = artwork + self.updateNowPlaying() + } + } + } + } + + // MARK: - Audio Session + + private func configureAudioSession() { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + // Non-fatal + } + } + + // MARK: - Lock Screen / Control Center + + private func setupRemoteCommandCenter() { + let center = MPRemoteCommandCenter.shared() + center.playCommand.addTarget { [weak self] _ in + self?.play() + return .success + } + center.pauseCommand.addTarget { [weak self] _ in + self?.pause() + return .success + } + center.togglePlayPauseCommand.addTarget { [weak self] _ in + self?.togglePlayPause() + return .success + } + center.skipForwardCommand.preferredIntervals = [15] + center.skipForwardCommand.addTarget { [weak self] _ in + self?.skip(by: 15) + return .success + } + center.skipBackwardCommand.preferredIntervals = [15] + center.skipBackwardCommand.addTarget { [weak self] _ in + self?.skip(by: -15) + return .success + } + center.changePlaybackPositionCommand.addTarget { [weak self] event in + if let e = event as? MPChangePlaybackPositionCommandEvent { + self?.seek(to: e.positionTime) + } + return .success + } + } + + private func updateNowPlaying() { + var info: [String: Any] = [ + MPMediaItemPropertyTitle: chapterTitle.isEmpty ? "Chapter \(chapter)" : chapterTitle, + MPMediaItemPropertyArtist: bookTitle, + MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime, + MPMediaItemPropertyPlaybackDuration: duration, + MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? speed : 0.0 + ] + // Use cached artwork — downloaded once in prefetchCoverArtwork(). + if let artwork = cachedCoverArtwork { + info[MPMediaItemPropertyArtwork] = artwork + } + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } +} + +// MARK: - Supporting types + +enum AudioPlayerStatus: Equatable { + case idle + case generating // covers both "loading" and "generating TTS" phases + case ready + case error(String) + + static func == (lhs: AudioPlayerStatus, rhs: AudioPlayerStatus) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle), (.generating, .generating), (.ready, .ready): + return true + case (.error(let a), .error(let b)): + return a == b + default: + return false + } + } +} + +enum SleepTimerOption: Equatable { + case chapters(Int) // Stop after N chapters + case minutes(Int) // Stop after N minutes +} + +extension Notification.Name { + static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter") + static let skipToNextChapter = Notification.Name("skipToNextChapter") + static let skipToPrevChapter = Notification.Name("skipToPrevChapter") +} diff --git a/ios/LibNovel/LibNovel/Services/AuthStore.swift b/ios/LibNovel/LibNovel/Services/AuthStore.swift new file mode 100644 index 0000000..88505f4 --- /dev/null +++ b/ios/LibNovel/LibNovel/Services/AuthStore.swift @@ -0,0 +1,159 @@ +import Foundation +import Combine + +// MARK: - AuthStore +// Owns the authenticated user, the HMAC auth token, and user settings. +// Persists the token to Keychain so the user stays logged in across launches. + +@MainActor +final class AuthStore: ObservableObject { + @Published var user: AppUser? + @Published var settings: UserSettings = .default + @Published var isLoading: Bool = false + @Published var error: String? + + var isAuthenticated: Bool { user != nil } + + private let keychainKey = "libnovel_auth_token" + + init() { + // Restore token from Keychain and validate it on launch + if let token = loadToken() { + Task { await validateToken(token) } + } + } + + // MARK: - Login / Register + + func login(username: String, password: String) async { + isLoading = true + error = nil + do { + let response = try await APIClient.shared.login(username: username, password: password) + await APIClient.shared.setAuthCookie(response.token) + saveToken(response.token) + user = response.user + await loadSettings() + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func register(username: String, password: String) async { + isLoading = true + error = nil + do { + let response = try await APIClient.shared.register(username: username, password: password) + await APIClient.shared.setAuthCookie(response.token) + saveToken(response.token) + user = response.user + await loadSettings() + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func logout() async { + do { + try await APIClient.shared.logout() + } catch { + // Best-effort; clear local state regardless + } + clearToken() + user = nil + settings = .default + } + + // MARK: - Settings + + func loadSettings() async { + do { + settings = try await APIClient.shared.settings() + } catch { + // Use defaults if settings endpoint fails + } + } + + func saveSettings(_ updated: UserSettings) async { + do { + try await APIClient.shared.updateSettings(updated) + settings = updated + } catch { + self.error = error.localizedDescription + } + } + + // MARK: - Token validation + + /// Re-validates the current session and refreshes `user` + `settings`. + /// Call this after any operation that may change the user record (e.g. avatar upload). + func validateToken() async { + guard let token = loadToken() else { return } + await validateToken(token) + } + + private func validateToken(_ token: String) async { + await APIClient.shared.setAuthCookie(token) + // Use /api/auth/me to restore the user record and confirm the token is still valid + do { + async let me: AppUser = APIClient.shared.fetch("/api/auth/me") + async let s: UserSettings = APIClient.shared.settings() + var (restoredUser, restoredSettings) = try await (me, s) + // /api/auth/me returns the raw MinIO object key for avatar_url, not a presigned URL. + // Exchange the key for a fresh presigned GET URL so KFImage can display it. + if let key = restoredUser.avatarURL, !key.hasPrefix("http") { + if let presignedURL = try? await APIClient.shared.fetchAvatarPresignedURL() { + restoredUser = AppUser( + id: restoredUser.id, + username: restoredUser.username, + role: restoredUser.role, + created: restoredUser.created, + avatarURL: presignedURL + ) + } + } + user = restoredUser + settings = restoredSettings + } catch let e as APIError { + if case .httpError(let code, _) = e, code == 401 { + clearToken() + } + } catch {} + } + + // MARK: - Keychain helpers + + private func saveToken(_ token: String) { + let data = Data(token.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: keychainKey, + kSecValueData as String: data + ] + SecItemDelete(query as CFDictionary) + SecItemAdd(query as CFDictionary, nil) + } + + private func loadToken() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: keychainKey, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private func clearToken() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: keychainKey + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/ios/LibNovel/LibNovel/Services/BookVoicePreferences.swift b/ios/LibNovel/LibNovel/Services/BookVoicePreferences.swift new file mode 100644 index 0000000..2d6b500 --- /dev/null +++ b/ios/LibNovel/LibNovel/Services/BookVoicePreferences.swift @@ -0,0 +1,73 @@ +import Foundation + +// MARK: - Book Voice Preferences Service +// Manages per-book voice overrides with global fallback + +@MainActor +final class BookVoicePreferences: ObservableObject { + static let shared = BookVoicePreferences() + + @Published private(set) var bookVoices: [String: String] = [:] // slug -> voice + + private let userDefaults = UserDefaults.standard + private let storageKey = "bookVoicePreferences" + + private init() { + loadPreferences() + } + + // MARK: - Public API + + /// Get the voice for a specific book (returns nil if no override set) + func voice(for slug: String) -> String? { + return bookVoices[slug] + } + + /// Get the voice for a book with fallback to global user voice + func voiceWithFallback(for slug: String, globalVoice: String) -> String { + return bookVoices[slug] ?? globalVoice + } + + /// Set a voice override for a specific book + func setVoice(_ voice: String, for slug: String) { + print("📚 BookVoicePreferences: Setting voice '\(voice)' for book '\(slug)'") + bookVoices[slug] = voice + savePreferences() + } + + /// Remove voice override for a book (will use global voice) + func removeVoice(for slug: String) { + print("📚 BookVoicePreferences: Removing voice override for book '\(slug)'") + bookVoices.removeValue(forKey: slug) + savePreferences() + } + + /// Check if a book has a voice override + func hasOverride(for slug: String) -> Bool { + return bookVoices[slug] != nil + } + + /// Clear all book voice overrides + func clearAll() { + print("📚 BookVoicePreferences: Clearing all book voice overrides") + bookVoices.removeAll() + savePreferences() + } + + // MARK: - Persistence + + private func loadPreferences() { + if let data = userDefaults.data(forKey: storageKey), + let decoded = try? JSONDecoder().decode([String: String].self, from: data) { + bookVoices = decoded + print("📚 BookVoicePreferences: Loaded \(bookVoices.count) book voice overrides") + } + } + + private func savePreferences() { + if let encoded = try? JSONEncoder().encode(bookVoices) { + userDefaults.set(encoded, forKey: storageKey) + print("📚 BookVoicePreferences: Saved \(bookVoices.count) book voice overrides") + } + } +} diff --git a/ios/LibNovel/LibNovel/Services/NetworkMonitor.swift b/ios/LibNovel/LibNovel/Services/NetworkMonitor.swift new file mode 100644 index 0000000..26bdfbf --- /dev/null +++ b/ios/LibNovel/LibNovel/Services/NetworkMonitor.swift @@ -0,0 +1,54 @@ +import Foundation +import Network + +// MARK: - Network Monitor +// Monitors network connectivity and provides offline state across the app + +@MainActor +final class NetworkMonitor: ObservableObject { + static let shared = NetworkMonitor() + + @Published var isConnected: Bool = true + @Published var connectionType: NWInterface.InterfaceType? + + private let monitor: NWPathMonitor + private let queue = DispatchQueue(label: "NetworkMonitor") + + init() { + monitor = NWPathMonitor() + startMonitoring() + } + + private func startMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + Task { @MainActor [weak self] in + self?.isConnected = path.status == .satisfied + self?.connectionType = path.availableInterfaces.first?.type + + if path.status == .satisfied { + print("🌐 Network: Connected (\(path.availableInterfaces.first?.type.debugDescription ?? "unknown"))") + } else { + print("📴 Network: Offline") + } + } + } + monitor.start(queue: queue) + } + + deinit { + monitor.cancel() + } +} + +extension NWInterface.InterfaceType { + var debugDescription: String { + switch self { + case .wifi: return "Wi-Fi" + case .cellular: return "Cellular" + case .wiredEthernet: return "Ethernet" + case .loopback: return "Loopback" + case .other: return "Other" + @unknown default: return "Unknown" + } + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift new file mode 100644 index 0000000..a98dd5b --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift @@ -0,0 +1,47 @@ +import Foundation + +@MainActor +final class BookDetailViewModel: ObservableObject { + let slug: String + + @Published var book: Book? + @Published var chapters: [ChapterIndex] = [] + @Published var saved: Bool = false + @Published var lastChapter: Int? + @Published var isLoading = false + @Published var error: String? + + init(slug: String) { + self.slug = slug + } + + func load() async { + isLoading = true + error = nil + do { + let detail = try await APIClient.shared.bookDetail(slug: slug) + book = detail.book + chapters = detail.chapters + saved = detail.saved + lastChapter = detail.lastChapter + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } + + func toggleSaved() async { + do { + if saved { + try await APIClient.shared.unsaveBook(slug: slug) + } else { + try await APIClient.shared.saveBook(slug: slug) + } + saved.toggle() + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift new file mode 100644 index 0000000..558c7a1 --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift @@ -0,0 +1,73 @@ +import Foundation + +@MainActor +final class BrowseViewModel: ObservableObject { + @Published var novels: [BrowseNovel] = [] + @Published var sort: String = "popular" + @Published var genre: String = "all" + @Published var status: String = "all" + @Published var searchQuery: String = "" + @Published var isLoading = false + @Published var hasNext = false + @Published var error: String? + + private var currentPage = 1 + private var isSearchMode = false + + func loadFirstPage() async { + currentPage = 1 + novels = [] + isSearchMode = false + await loadPage(1) + } + + func loadNextPage() async { + guard hasNext, !isLoading else { return } + await loadPage(currentPage + 1) + } + + func search() async { + guard !searchQuery.isEmpty else { await loadFirstPage(); return } + isLoading = true + isSearchMode = true + novels = [] + error = nil + do { + let result = try await APIClient.shared.search(query: searchQuery) + novels = result.results + hasNext = false + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } + + func clearSearch() { + searchQuery = "" + Task { await loadFirstPage() } + } + + private func loadPage(_ page: Int) async { + isLoading = true + error = nil + do { + let result = try await APIClient.shared.browse( + page: page, genre: genre, sort: sort, status: status + ) + if page == 1 { + novels = result.novels + } else { + novels.append(contentsOf: result.novels) + } + hasNext = result.hasNext + currentPage = page + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift new file mode 100644 index 0000000..cf0b39a --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift @@ -0,0 +1,73 @@ +import Foundation + +@MainActor +final class ChapterReaderViewModel: ObservableObject { + let slug: String + private(set) var chapter: Int + + @Published var content: ChapterResponse? + @Published var isLoading = false + @Published var error: String? + + init(slug: String, chapter: Int) { + self.slug = slug + self.chapter = chapter + } + + /// Switch to a different chapter in-place: resets state and updates `chapter` + /// so that `.task(id: currentChapter)` in the View re-fires `load()`. + func switchChapter(to newChapter: Int) { + guard newChapter != chapter else { return } + chapter = newChapter + content = nil + error = nil + } + + func load() async { + isLoading = true + error = nil + do { + content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter) + // Record reading progress + try? await APIClient.shared.setProgress(slug: slug, chapter: chapter) + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } + + func toggleAudio(audioPlayer: AudioPlayerService, settings: UserSettings) { + guard let content else { return } + + // Only treat as "current" if the player is active (not idle/stopped). + // If the user stopped playback, isActive is false — we must re-load. + let isCurrent = audioPlayer.isActive && + audioPlayer.slug == slug && + audioPlayer.chapter == chapter + + if isCurrent { + audioPlayer.togglePlayPause() + } else { + let nextChapter: Int? = content.next + let prevChapter: Int? = content.prev + + // Use per-book voice override, fallback to global voice + let voice = BookVoicePreferences.shared.voiceWithFallback(for: slug, globalVoice: settings.voice) + + audioPlayer.load( + slug: slug, + chapter: chapter, + chapterTitle: content.chapter.title, + bookTitle: content.book.title, + coverURL: content.book.cover, + voice: voice, + speed: settings.speed, + chapters: content.chapters, + nextChapter: nextChapter, + prevChapter: prevChapter + ) + } + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/DiscoverViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/DiscoverViewModel.swift new file mode 100644 index 0000000..b128aa6 --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/DiscoverViewModel.swift @@ -0,0 +1,78 @@ +import Foundation + +@MainActor +final class DiscoverViewModel: ObservableObject { + @Published var trending: [BrowseNovel] = [] + @Published var topRated: [BrowseNovel] = [] + @Published var recentlyUpdated: [BrowseNovel] = [] + @Published var newReleases: [BrowseNovel] = [] + @Published var genreShelves: [GenreShelf] = [] + @Published var isLoading = false + @Published var error: String? + + struct GenreShelf: Identifiable { + let id: String + let name: String + let genre: String + var novels: [BrowseNovel] = [] + } + + // Popular genres to show as shelves + private let featuredGenres = [ + ("fantasy", "Fantasy"), + ("romance", "Romance"), + ("action", "Action"), + ("sci-fi", "Sci-Fi"), + ("mystery", "Mystery") + ] + + func load() async { + guard !isLoading else { return } + isLoading = true + error = nil + + async let trendingTask = loadShelf(sort: "popular", limit: 20) + async let topRatedTask = loadShelf(sort: "rating", limit: 20) + async let recentlyUpdatedTask = loadShelf(sort: "updated", limit: 20) + async let newReleasesTask = loadShelf(sort: "new", limit: 20) + + do { + trending = try await trendingTask + topRated = try await topRatedTask + recentlyUpdated = try await recentlyUpdatedTask + newReleases = try await newReleasesTask + + // Load genre shelves + await loadGenreShelves() + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + + isLoading = false + } + + private func loadShelf(sort: String, genre: String = "all", status: String = "all", limit: Int = 20) async throws -> [BrowseNovel] { + let result = try await APIClient.shared.browse(page: 1, genre: genre, sort: sort, status: status) + return Array(result.novels.prefix(limit)) + } + + private func loadGenreShelves() async { + var shelves: [GenreShelf] = [] + + for (genre, name) in featuredGenres { + do { + let novels = try await loadShelf(sort: "popular", genre: genre, limit: 15) + if !novels.isEmpty { + shelves.append(GenreShelf(id: genre, name: name, genre: genre, novels: novels)) + } + } catch { + // Skip failed genres silently + continue + } + } + + genreShelves = shelves + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift new file mode 100644 index 0000000..503d971 --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift @@ -0,0 +1,30 @@ +import Foundation + +@MainActor +final class HomeViewModel: ObservableObject { + @Published var continueReading: [ContinueReadingItem] = [] + @Published var recentlyUpdated: [Book] = [] + @Published var stats: HomeStats? + @Published var subscriptionFeed: [SubscriptionFeedItem] = [] + @Published var isLoading = false + @Published var error: String? + + func load() async { + isLoading = true + error = nil + do { + let data = try await APIClient.shared.homeData() + continueReading = data.continueReading.map { + ContinueReadingItem(book: $0.book, chapter: $0.chapter) + } + recentlyUpdated = data.recentlyUpdated + stats = data.stats + subscriptionFeed = data.subscriptionFeed + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift new file mode 100644 index 0000000..50dd87d --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift @@ -0,0 +1,21 @@ +import Foundation + +@MainActor +final class LibraryViewModel: ObservableObject { + @Published var items: [LibraryItem] = [] + @Published var isLoading = false + @Published var error: String? + + func load() async { + isLoading = true + error = nil + do { + items = try await APIClient.shared.library() + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/ProfileViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/ProfileViewModel.swift new file mode 100644 index 0000000..adde55f --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/ProfileViewModel.swift @@ -0,0 +1,40 @@ +import Foundation + +@MainActor +final class ProfileViewModel: ObservableObject { + @Published var sessions: [UserSession] = [] + @Published var voices: [String] = [] + @Published var sessionsLoading = false + @Published var error: String? + + func loadSessions() async { + sessionsLoading = true + do { + sessions = try await APIClient.shared.sessions() + } catch { + self.error = error.localizedDescription + } + sessionsLoading = false + } + + func loadVoices() async { + guard voices.isEmpty else { return } + do { + voices = try await APIClient.shared.voices() + } catch { + // Use hardcoded fallback — same as Go server helpers.go + voices = ["af_bella", "af_sky", "af_sarah", "af_nicole", + "am_adam", "am_michael", "bf_emma", "bf_isabella", + "bm_george", "bm_lewis"] + } + } + + func revokeSession(id: String) async { + do { + try await APIClient.shared.revokeSession(id: id) + sessions.removeAll { $0.id == id } + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift new file mode 100644 index 0000000..ce27872 --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift @@ -0,0 +1,87 @@ +import Foundation + +@MainActor +final class UserProfileViewModel: ObservableObject { + let username: String + + @Published var profile: PublicUserProfile? + @Published var currentlyReading: [PublicLibraryItem] = [] + @Published var library: [PublicLibraryItem] = [] + @Published var isLoading = false + @Published var isTogglingSubscribe = false + @Published var error: String? + + init(username: String) { + self.username = username + } + + func load() async { + guard !isLoading else { return } + isLoading = true + error = nil + do { + async let profileFetch = APIClient.shared.fetchUserProfile(username: username) + async let libraryFetch = APIClient.shared.fetchUserLibrary(username: username) + let (p, lib) = try await (profileFetch, libraryFetch) + profile = p + currentlyReading = lib.currentlyReading + library = lib.library + } catch let apiError as APIError { + switch apiError { + case .httpError(404, _): error = "User not found." + default: error = apiError.localizedDescription + } + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } + + func toggleSubscribe() async { + guard let p = profile, !p.isSelf, !isTogglingSubscribe else { return } + isTogglingSubscribe = true + defer { isTogglingSubscribe = false } + do { + if p.isSubscribed { + try await APIClient.shared.unsubscribeUser(username: username) + profile = PublicUserProfile( + id: p.id, username: p.username, avatarUrl: p.avatarUrl, + created: p.created, + followerCount: max(0, p.followerCount - 1), + followingCount: p.followingCount, + isSubscribed: false, isSelf: p.isSelf + ) + } else { + try await APIClient.shared.subscribeUser(username: username) + profile = PublicUserProfile( + id: p.id, username: p.username, avatarUrl: p.avatarUrl, + created: p.created, + followerCount: p.followerCount + 1, + followingCount: p.followingCount, + isSubscribed: true, isSelf: p.isSelf + ) + } + } catch { + self.error = error.localizedDescription + } + } +} + +// MARK: - Convenience memberwise init for PublicUserProfile (used in optimistic updates) + +private extension PublicUserProfile { + init(id: String, username: String, avatarUrl: String?, created: String, + followerCount: Int, followingCount: Int, isSubscribed: Bool, isSelf: Bool) { + // Encode then decode to go through the standard Decodable path without duplicating code + var dict: [String: Any] = [ + "id": id, "username": username, "created": created, + "followerCount": followerCount, "followingCount": followingCount, + "isSubscribed": isSubscribed, "isSelf": isSelf + ] + if let url = avatarUrl { dict["avatarUrl"] = url } + let data = try! JSONSerialization.data(withJSONObject: dict) + self = try! JSONDecoder().decode(PublicUserProfile.self, from: data) + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/VoiceSelectionViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/VoiceSelectionViewModel.swift new file mode 100644 index 0000000..785c722 --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/VoiceSelectionViewModel.swift @@ -0,0 +1,127 @@ +import Foundation +import AVFoundation + +@MainActor +class VoiceSelectionViewModel: ObservableObject { + @Published var voices: [String] = [] + @Published var isLoading = false + @Published var error: String? + @Published var playingVoice: String? + + private var audioPlayer: AVPlayer? + // Store the opaque token returned by the block-based addObserver so we can + // actually remove it later. removeObserver(self, ...) does nothing when the + // block-based API was used — the token is the observer, not `self`. + private var endObserverToken: NSObjectProtocol? + + // Voice label formatting (matches web UI logic) + func voiceLabel(_ voice: String) -> String { + let parts = voice.split(separator: "_") + guard parts.count >= 2 else { return voice } + + let prefix = String(parts[0]) + let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ") + + var info = "" + switch prefix { + case "af": info = "US F" + case "am": info = "US M" + case "bf": info = "UK F" + case "bm": info = "UK M" + default: info = prefix.uppercased() + } + + return "\(name) (\(info))" + } + + func voiceId(_ voice: String) -> String { voice } + + // Load available voices from API + func loadVoices() async { + isLoading = true + error = nil + defer { isLoading = false } + + do { + let fetchedVoices = try await APIClient.shared.voices() + voices = fetchedVoices.isEmpty ? fallbackVoices() : fetchedVoices + } catch { + self.error = "Failed to load voices: \(error.localizedDescription)" + voices = fallbackVoices() + } + } + + // Play voice sample + func playSample(_ voice: String) async { + if playingVoice == voice { + stopSample() + return + } + + stopSample() + playingVoice = voice + + do { + let presignedURL = try await APIClient.shared.presignVoiceSample(voice: voice) + guard let url = URL(string: presignedURL) else { + throw NSError(domain: "VoiceSelection", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]) + } + + let playerItem = AVPlayerItem(url: url) + audioPlayer = AVPlayer(playerItem: playerItem) + + // Block-based addObserver returns a token — store it so we can remove it. + endObserverToken = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: playerItem, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.stopSample() + } + } + + audioPlayer?.play() + } catch { + // Sample might not be generated yet — silently ignore. + print("Voice sample not available for \(voice): \(error)") + playingVoice = nil + } + } + + // Stop currently playing sample + func stopSample() { + audioPlayer?.pause() + audioPlayer = nil + playingVoice = nil + if let token = endObserverToken { + NotificationCenter.default.removeObserver(token) + endObserverToken = nil + } + } + + private func fallbackVoices() -> [String] { + ["af_bella", "af_sarah", "af_nicole", + "am_adam", "am_michael", + "bf_emma", "bf_isabella", + "bm_george", "bm_lewis", + "af_sky"] + } + + // deinit: must NOT dispatch a Task capturing self. + // A Task strongly retains self, which causes "deallocated with non-zero retain + // count 2" → SIGABRT. Instead capture just the two values we need (player and + // token) and clean up without touching self at all. + nonisolated deinit { + // Capture locals — self is going away, do not reference it after this point. + // audioPlayer and endObserverToken are actor-isolated, but we can read their + // stored value directly in deinit because deinit is the last exclusive owner. + // Suppress the "actor-isolated" warning with an unowned reference pattern: + // Swift SE-0371 allows nonisolated deinit to access stored properties directly. + audioPlayer?.pause() + if let token = endObserverToken { + NotificationCenter.default.removeObserver(token) + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/Auth/AuthView.swift b/ios/LibNovel/LibNovel/Views/Auth/AuthView.swift new file mode 100644 index 0000000..e0c2d3d --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Auth/AuthView.swift @@ -0,0 +1,123 @@ +import SwiftUI + +struct AuthView: View { + @EnvironmentObject var authStore: AuthStore + @State private var mode: Mode = .login + @State private var username: String = "" + @State private var password: String = "" + @State private var confirmPassword: String = "" + @FocusState private var focusedField: Field? + + enum Mode { case login, register } + enum Field { case username, password, confirmPassword } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Logo / header + VStack(spacing: 8) { + Image(systemName: "books.vertical.fill") + .font(.system(size: 56)) + .foregroundStyle(.amber) + Text("LibNovel") + .font(.largeTitle.bold()) + } + .padding(.top, 60) + .padding(.bottom, 40) + + // Tab switcher + Picker("Mode", selection: $mode) { + Text("Sign In").tag(Mode.login) + Text("Create Account").tag(Mode.register) + } + .pickerStyle(.segmented) + .padding(.horizontal, 24) + .padding(.bottom, 32) + + // Form + VStack(spacing: 16) { + TextField("Username", text: $username) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focusedField, equals: .username) + .submitLabel(.next) + .onSubmit { focusedField = .password } + + SecureField("Password", text: $password) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .password) + .submitLabel(mode == .register ? .next : .go) + .onSubmit { + if mode == .register { focusedField = .confirmPassword } + else { submit() } + } + + if mode == .register { + SecureField("Confirm Password", text: $confirmPassword) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .confirmPassword) + .submitLabel(.go) + .onSubmit { submit() } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.horizontal, 24) + .animation(.easeInOut(duration: 0.2), value: mode) + + if let error = authStore.error { + Text(error) + .font(.footnote) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.top, 8) + } + + Button(action: submit) { + Group { + if authStore.isLoading { + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + } else { + Text(mode == .login ? "Sign In" : "Create Account") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + } + .buttonStyle(.borderedProminent) + .tint(.amber) + .padding(.horizontal, 24) + .padding(.top, 24) + .disabled(authStore.isLoading || !formIsValid) + + Spacer() + } + .toolbar(.hidden, for: .navigationBar) + } + .onChange(of: mode) { _, _ in + authStore.error = nil + confirmPassword = "" + } + } + + private var formIsValid: Bool { + let base = !username.isEmpty && password.count >= 4 + if mode == .register { return base && password == confirmPassword } + return base + } + + private func submit() { + focusedField = nil + Task { + if mode == .login { + await authStore.login(username: username, password: password) + } else { + await authStore.register(username: username, password: password) + } + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift new file mode 100644 index 0000000..17df86b --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift @@ -0,0 +1,708 @@ +import SwiftUI +import Kingfisher + +struct BookDetailView: View { + let slug: String + @StateObject private var vm: BookDetailViewModel + @EnvironmentObject var authStore: AuthStore + @EnvironmentObject var audioPlayer: AudioPlayerService + @State private var summaryExpanded = false + @State private var showChapters = false + + init(slug: String) { + self.slug = slug + _vm = StateObject(wrappedValue: BookDetailViewModel(slug: slug)) + } + + var body: some View { + VStack(spacing: 0) { + OfflineBanner() + + ZStack(alignment: .top) { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if vm.isLoading { + ProgressView().frame(maxWidth: .infinity).padding(.top, 120) + } else if let book = vm.book { + heroSection(book: book) + metaSection(book: book) + Divider().padding(.horizontal) + chaptersRow(book: book) + Divider().padding(.horizontal) + CommentsView(slug: slug) + } + } + } + .ignoresSafeArea(edges: .top) + } + } + .navigationBarTitleDisplayMode(.inline) + .appNavigationDestination() + .toolbar { bookmarkButton } + .task { await vm.load() } + .errorAlert($vm.error) + .sheet(isPresented: $showChapters) { + BookChaptersSheet( + slug: slug, + chapters: vm.chapters, + lastChapter: vm.lastChapter, + totalChapters: vm.book?.totalChapters ?? 0 + ) + } + } + + // MARK: - Hero + + @ViewBuilder + private func heroSection(book: Book) -> some View { + ZStack(alignment: .bottom) { + // Full-bleed blurred background + KFImage(URL(string: book.cover)) + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity) + .frame(height: 320) + .blur(radius: 24) + .clipped() + .overlay( + LinearGradient( + colors: [.black.opacity(0.15), .black.opacity(0.68)], + startPoint: .top, + endPoint: .bottom + ) + ) + + VStack(spacing: 16) { + KFImage(URL(string: book.cover)) + .resizable() + .placeholder { + RoundedRectangle(cornerRadius: 12) + .fill(Color(.systemGray5)) + } + .scaledToFill() + .frame(width: 130, height: 188) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10) + .shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3) + + VStack(spacing: 6) { + Text(book.title) + .font(.title3.bold()) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .lineLimit(3) + .padding(.horizontal, 32) + + Text(book.author) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.75)) + } + + if !book.genres.isEmpty { + HStack(spacing: 8) { + ForEach(book.genres.prefix(3), id: \.self) { genre in + TagChip(label: genre).colorScheme(.dark) + } + } + } + + if !book.status.isEmpty { + StatusBadge(status: book.status) + } + } + .padding(.horizontal) + .padding(.bottom, 28) + } + .frame(minHeight: 320) + } + + // MARK: - Meta section (stats + summary + CTAs) + + @ViewBuilder + private func metaSection(book: Book) -> some View { + VStack(alignment: .leading, spacing: 0) { + // Quick stats row + HStack(spacing: 0) { + MetaStat(value: "\(book.totalChapters)", label: "Chapters", icon: "doc.text") + Divider().frame(height: 36) + MetaStat( + value: book.status.capitalized.isEmpty ? "—" : book.status.capitalized, + label: "Status", icon: "flag" + ) + if book.ranking > 0 { + Divider().frame(height: 36) + MetaStat(value: "#\(book.ranking)", label: "Rank", icon: "chart.bar.fill") + } + } + .padding(.vertical, 16) + .frame(maxWidth: .infinity) + + Divider().padding(.horizontal) + + // Summary + VStack(alignment: .leading, spacing: 8) { + Text("About") + .font(.headline) + + Text(book.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(summaryExpanded ? nil : 4) + .animation(.easeInOut(duration: 0.2), value: summaryExpanded) + + if book.summary.count > 200 { + Button(summaryExpanded ? "Less" : "More") { + withAnimation { summaryExpanded.toggle() } + } + .font(.caption.bold()) + .foregroundStyle(.amber) + } + } + .padding(.horizontal) + .padding(.vertical, 16) + + Divider().padding(.horizontal) + + // CTA buttons + HStack(spacing: 10) { + if let last = vm.lastChapter, last > 0 { + NavigationLink(value: NavDestination.chapter(slug, last)) { + Label("Continue Ch.\(last)", systemImage: "play.fill") + .frame(maxWidth: .infinity) + .fontWeight(.semibold) + } + .buttonStyle(.borderedProminent) + .tint(.amber) + + NavigationLink(value: NavDestination.chapter(slug, 1)) { + Label("From Ch.1", systemImage: "arrow.counterclockwise") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.secondary) + } else { + NavigationLink(value: NavDestination.chapter(slug, 1)) { + Label("Start Reading", systemImage: "book.fill") + .frame(maxWidth: .infinity) + .fontWeight(.semibold) + } + .buttonStyle(.borderedProminent) + .tint(.amber) + } + } + .padding(.horizontal) + .padding(.vertical, 16) + } + } + + // MARK: - Compact chapters row (tap → sheet) + + @ViewBuilder + private func chaptersRow(book: Book) -> some View { + Button { + showChapters = true + } label: { + HStack(spacing: 12) { + Image(systemName: "list.number") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.amber) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text("Chapters") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + if !vm.chapters.isEmpty { + let last = vm.lastChapter + let total = vm.chapters.count + Text(last != nil && last! > 0 + ? "Reading Ch.\(last!) of \(total)" + : "\(total) chapter\(total == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + } else if vm.isLoading { + Text("Loading…") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + // MARK: - Bookmark toolbar + + @ToolbarContentBuilder + private var bookmarkButton: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await vm.toggleSaved() } + } label: { + Image(systemName: vm.saved ? "bookmark.fill" : "bookmark") + .foregroundStyle(vm.saved ? .amber : .primary) + } + } + } +} + +// MARK: - Chapters list sheet +// Apple Books-style: chapters grouped into blocks of 100 with a right-edge jump bar. +// A .searchable bar filters by number or title; an "offline only" toggle shows downloaded chapters. +// Per-row download status (arc ring, labels, swipe actions) mirrors ChaptersListSheet in PlayerViews. + +struct BookChaptersSheet: View { + let slug: String + let chapters: [ChapterIndex] + let lastChapter: Int? + let totalChapters: Int + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var downloadService: AudioDownloadService + @EnvironmentObject var audioPlayer: AudioPlayerService + + @State private var searchText: String = "" + @State private var filterOfflineOnly = false + @State private var showingDownloadAll = false + /// The block label the jump bar is currently scrolling to (e.g. "1–100"). + @State private var activeBlock: String? = nil + + // MARK: Derived data + + private var downloadedCount: Int { + chapters.filter { ch in + downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: defaultVoice) + }.count + } + + private var downloadingCount: Int { + downloadService.downloads.filter { key, _ in + key.hasPrefix("\(slug)::") + }.count + } + + private var defaultVoice: String { + BookVoicePreferences.shared.voiceWithFallback(for: slug, globalVoice: audioPlayer.voice) + } + + private var filtered: [ChapterIndex] { + var result = chapters + + if filterOfflineOnly { + result = result.filter { ch in + downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: defaultVoice) + } + } + + if !searchText.isEmpty { + let q = searchText.lowercased() + result = result.filter { + "\($0.number)".contains(q) || + $0.title.lowercased().contains(q) || + "chapter \($0.number)".contains(q) + } + } + + return result + } + + /// Chapters grouped into blocks of 100 with range labels "1–100", "101–200", etc. + /// When searching or filtering the jump bar is hidden and a flat "Results" group is used. + private var groups: [(label: String, chapters: [ChapterIndex])] { + guard searchText.isEmpty && !filterOfflineOnly else { + return filtered.isEmpty ? [] : [("Results", filtered)] + } + guard !filtered.isEmpty else { return [] } + let blockSize = 100 + let minN = filtered.map(\.number).min() ?? 1 + let maxN = filtered.map(\.number).max() ?? 1 + let firstBlock = ((minN - 1) / blockSize) * blockSize + 1 + var result: [(label: String, chapters: [ChapterIndex])] = [] + var blockStart = firstBlock + while blockStart <= maxN { + let blockEnd = blockStart + blockSize - 1 + let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd } + if !slice.isEmpty { + result.append(("\(blockStart)–\(blockEnd)", slice)) + } + blockStart += blockSize + } + return result + } + + private var jumpLabels: [String] { groups.map(\.label) } + + // MARK: Body + + var body: some View { + NavigationStack { + ZStack(alignment: .trailing) { + // ── Main chapter list ────────────────────────────────────── + List { + // Offline downloads summary (shown when at least one chapter is downloaded) + if downloadedCount > 0 || downloadingCount > 0 { + Section { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Offline Downloads") + .font(.headline) + Text("\(downloadedCount) of \(chapters.count) chapters") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + Button { + showingDownloadAll = true + } label: { + Label("Manage", systemImage: "arrow.down.circle") + .font(.subheadline.weight(.semibold)) + } + .buttonStyle(.bordered) + .tint(.blue) + } + + if downloadingCount > 0 { + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.8) + Text("Downloading \(downloadingCount) \(downloadingCount == 1 ? "chapter" : "chapters")") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Toggle("Show offline only", isOn: $filterOfflineOnly) + .font(.subheadline) + .tint(.amber) + } + .padding(.vertical, 8) + } + } + + ForEach(groups, id: \.label) { group in + Section { + ForEach(group.chapters, id: \.number) { ch in + BookChapterRow( + chapter: ch, + slug: slug, + isCurrent: ch.number == lastChapter, + voice: defaultVoice + ) + .id(group.label) + } + } header: { + if searchText.isEmpty && !filterOfflineOnly { + Text(group.label) + .font(.caption.bold()) + .foregroundStyle(.secondary) + .id("header_\(group.label)") + } + } + } + + if chapters.isEmpty { + Section { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .listRowBackground(Color.clear) + } + } + } + .listStyle(.plain) + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Chapter number or title" + ) + .scrollPosition(id: $activeBlock, anchor: .top) + .appNavigationDestination() + + // ── Right-edge jump bar ──────────────────────────────────── + if searchText.isEmpty && !filterOfflineOnly && jumpLabels.count > 1 { + BookChaptersJumpBar( + labels: jumpLabels, + currentChapter: lastChapter ?? 0, + groups: groups + ) { label in + withAnimation { activeBlock = label } + } + .padding(.trailing, 4) + } + } + .navigationTitle("Chapters (\(filtered.count))") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + } + } + // Sheet to manage bulk downloads for this book + .sheet(isPresented: $showingDownloadAll) { + DownloadManagementSheet( + chapters: chapters.map { ChapterIndexBrief(number: $0.number, title: $0.title) }, + slug: slug, + voice: Binding( + get: { defaultVoice }, + set: { _ in } // voice changes handled inside DownloadManagementSheet + ) + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + // Scroll to the current chapter's block on first appear + .onAppear { + if let block = groups.first(where: { g in + g.chapters.contains(where: { $0.number == (lastChapter ?? 0) }) + }) { + activeBlock = block.label + } + } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } +} + +// MARK: - Individual chapter row with download status + NavigationLink + +private struct BookChapterRow: View { + let chapter: ChapterIndex + let slug: String + let isCurrent: Bool + let voice: String + + @EnvironmentObject var downloadService: AudioDownloadService + + private var isDownloaded: Bool { + downloadService.isDownloaded(slug: slug, chapter: chapter.number, voice: voice) + } + + private var downloadProgress: DownloadProgress? { + let key = downloadService.makeKey(slug: slug, chapter: chapter.number, voice: voice) + return downloadService.downloads[key] + } + + private var isDownloading: Bool { downloadProgress != nil } + + private var displayTitle: String { + let stripped = chapter.title.strippingTrailingDate() + if stripped.isEmpty || stripped == "Chapter \(chapter.number)" { + return "Chapter \(chapter.number)" + } + return stripped + } + + var body: some View { + NavigationLink(value: NavDestination.chapter(slug, chapter.number)) { + HStack(spacing: 14) { + // Number badge with optional download-progress arc ring + ZStack { + Circle() + .fill(isCurrent ? Color.amber : Color(.systemGray5)) + .frame(width: 40, height: 40) + + Text("\(chapter.number)") + .font(.caption.bold().monospacedDigit()) + .foregroundStyle(isCurrent ? .white : .secondary) + .minimumScaleFactor(0.6) + .frame(width: 40, height: 40) + + // In-progress download arc + if isDownloading, let progress = downloadProgress { + Circle() + .trim(from: 0, to: progress.progress) + .stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .frame(width: 44, height: 44) + .animation(.easeInOut(duration: 0.3), value: progress.progress) + } + } + + // Title + status subtitle + VStack(alignment: .leading, spacing: 3) { + Text(displayTitle) + .font(.subheadline.weight(isCurrent ? .semibold : .regular)) + .foregroundStyle(isCurrent ? .amber : .primary) + .lineLimit(1) + + HStack(spacing: 8) { + if isCurrent { + Label("Reading", systemImage: "bookmark.fill") + .font(.caption2) + .foregroundStyle(.amber) + } + + if isDownloading, let progress = downloadProgress { + Label("\(Int(progress.progress * 100))%", systemImage: "arrow.down.circle") + .font(.caption2) + .foregroundStyle(.blue) + } else if isDownloaded { + Label("Downloaded", systemImage: "checkmark.circle.fill") + .font(.caption2) + .foregroundStyle(.green) + } else if !chapter.dateLabel.isEmpty { + Text(chapter.dateLabel) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + + Spacer(minLength: 4) + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear) + // Trailing swipe: Download / Cancel / Delete + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if isDownloaded { + Button(role: .destructive) { + Task { + try? downloadService.deleteDownload( + slug: slug, chapter: chapter.number, voice: voice + ) + } + } label: { + Label("Delete", systemImage: "trash") + } + } else if isDownloading { + Button(role: .destructive) { + downloadService.cancelDownload( + slug: slug, chapter: chapter.number, voice: voice + ) + } label: { + Label("Cancel", systemImage: "xmark") + } + } else { + Button { + Task { + try? await downloadService.download( + slug: slug, chapter: chapter.number, voice: voice + ) + } + } label: { + Label("Download", systemImage: "arrow.down.circle") + } + .tint(.blue) + } + } + } +} + +// MARK: - Right-edge jump bar for BookChaptersSheet +// Mirrors the JumpBar in PlayerViews.swift but operates on ChapterIndex groups. + +private struct BookChaptersJumpBar: View { + let labels: [String] + let currentChapter: Int + let groups: [(label: String, chapters: [ChapterIndex])] + let onSelect: (String) -> Void + + @State private var isDragging = false + + private func shortLabel(_ full: String) -> String { + full.components(separatedBy: "–").first ?? full + } + + private var currentBlock: String? { + groups.first(where: { g in g.chapters.contains(where: { $0.number == currentChapter }) })?.label + } + + var body: some View { + VStack(spacing: 0) { + ForEach(labels, id: \.self) { label in + let isCurrent = label == currentBlock + Text(shortLabel(label)) + .font(.system(size: 10, weight: isCurrent ? .bold : .regular)) + .foregroundStyle(isCurrent ? Color.amber : Color.secondary) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + .onTapGesture { onSelect(label) } + } + } + .padding(.vertical, 6) + .background( + Capsule() + .fill(.ultraThinMaterial) + .shadow(color: .black.opacity(0.15), radius: 4) + ) + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .local) + .onChanged { value in + isDragging = true + let itemHeight: CGFloat = 28 + let index = Int(value.location.y / itemHeight) + let clamped = max(0, min(labels.count - 1, index)) + onSelect(labels[clamped]) + } + .onEnded { _ in isDragging = false } + ) + .animation(.easeInOut(duration: 0.15), value: isDragging) + } +} + +// MARK: - Supporting components + +private struct MetaStat: View { + let value: String + let label: String + let icon: String + + var body: some View { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.caption) + .foregroundStyle(.amber) + Text(value) + .font(.subheadline.bold()) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } +} + +private struct StatusBadge: View { + let status: String + + private var color: Color { + switch status.lowercased() { + case "ongoing", "active": return .green + case "completed": return .blue + case "hiatus": return .orange + default: return .secondary + } + } + + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + Text(status.capitalized) + .font(.caption.weight(.medium)) + .foregroundStyle(color) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(color.opacity(0.12), in: Capsule()) + } +} diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift new file mode 100644 index 0000000..e040e57 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift @@ -0,0 +1,643 @@ +import SwiftUI + +// MARK: - ViewModel + +@MainActor +class CommentsViewModel: ObservableObject { + let slug: String + + @Published var comments: [BookComment] = [] + @Published var myVotes: [String: String] = [:] // commentId → "up" | "down" + @Published var avatarUrls: [String: String] = [:] // userId → presigned URL + @Published var isLoading = true + @Published var error: String? + + @Published var newBody = "" + @Published var isPosting = false + @Published var postError: String? + + @Published var sort: CommentSortOrder = .top + + // Reply state + @Published var replyingToId: String? = nil + @Published var replyBody = "" + @Published var isPostingReply = false + @Published var replyError: String? + + private var votingIds: Set = [] + private var deletingIds: Set = [] + + init(slug: String) { + self.slug = slug + } + + func load() async { + isLoading = true + error = nil + do { + let response = try await APIClient.shared.fetchComments(slug: slug, sort: sort.rawValue) + comments = response.comments + myVotes = response.myVotes + avatarUrls = response.avatarUrls + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func postComment() async { + let text = newBody.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isPosting else { return } + if text.count > 2000 { + postError = "Comment too long (max 2000 characters)." + return + } + isPosting = true + postError = nil + do { + var created = try await APIClient.shared.postComment(slug: slug, body: text) + created.replies = [] + comments.insert(created, at: 0) + newBody = "" + } catch let apiError as APIError { + switch apiError { + case .httpError(401, _): postError = "You must be logged in to comment." + default: postError = apiError.localizedDescription + } + } catch { + postError = error.localizedDescription + } + isPosting = false + } + + func postReply(parentId: String) async { + let text = replyBody.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isPostingReply else { return } + if text.count > 2000 { + replyError = "Reply too long (max 2000 characters)." + return + } + isPostingReply = true + replyError = nil + do { + let created = try await APIClient.shared.postComment(slug: slug, body: text, parentId: parentId) + if let idx = comments.firstIndex(where: { $0.id == parentId }) { + var parent = comments[idx] + var replies = parent.replies ?? [] + replies.append(created) + parent.replies = replies + comments[idx] = parent + } + replyBody = "" + replyingToId = nil + } catch let apiError as APIError { + switch apiError { + case .httpError(401, _): replyError = "You must be logged in to reply." + default: replyError = apiError.localizedDescription + } + } catch { + replyError = error.localizedDescription + } + isPostingReply = false + } + + func deleteComment(commentId: String, parentId: String? = nil) async { + guard !deletingIds.contains(commentId) else { return } + deletingIds.insert(commentId) + + // Optimistic removal — update the UI immediately before the network call + var removedComment: BookComment? + var removedAtIndex: Int? + if let parentId { + if let idx = comments.firstIndex(where: { $0.id == parentId }) { + var parent = comments[idx] + removedComment = parent.replies?.first(where: { $0.id == commentId }) + removedAtIndex = idx + parent.replies = (parent.replies ?? []).filter { $0.id != commentId } + comments[idx] = parent + } + } else { + removedAtIndex = comments.firstIndex(where: { $0.id == commentId }) + removedComment = removedAtIndex.map { comments[$0] } + comments.removeAll { $0.id == commentId } + } + + do { + try await APIClient.shared.deleteComment(commentId: commentId) + } catch { + // Revert the optimistic removal on failure + if let removed = removedComment { + if let parentId, let idx = removedAtIndex { + var parent = comments[idx] + var replies = parent.replies ?? [] + replies.append(removed) + replies.sort { $0.created < $1.created } + parent.replies = replies + comments[idx] = parent + } else if let idx = removedAtIndex { + comments.insert(removed, at: min(idx, comments.count)) + } + } + } + + deletingIds.remove(commentId) + } + + func vote(commentId: String, vote: String, parentId: String? = nil) async { + guard !votingIds.contains(commentId) else { return } + votingIds.insert(commentId) + defer { votingIds.remove(commentId) } + do { + let updated = try await APIClient.shared.voteComment(commentId: commentId, vote: vote) + if let parentId { + if let idx = comments.firstIndex(where: { $0.id == parentId }) { + var parent = comments[idx] + if let rIdx = parent.replies?.firstIndex(where: { $0.id == commentId }) { + parent.replies![rIdx] = updated + } + comments[idx] = parent + } + } else { + if let idx = comments.firstIndex(where: { $0.id == commentId }) { + var c = updated + c.replies = comments[idx].replies + comments[idx] = c + } + } + let prev = myVotes[commentId] + if prev == vote { + myVotes.removeValue(forKey: commentId) + } else { + myVotes[commentId] = vote + } + } catch { + // Silently ignore vote errors + } + } + + func isVoting(_ commentId: String) -> Bool { votingIds.contains(commentId) } + func isDeleting(_ commentId: String) -> Bool { deletingIds.contains(commentId) } + + func setSort(_ newSort: CommentSortOrder) { + guard newSort != sort else { return } + sort = newSort + Task { await load() } + } +} + +enum CommentSortOrder: String, CaseIterable { + case top = "top" + case new = "new" + + var label: String { + switch self { + case .top: return "Top" + case .new: return "New" + } + } +} + +// MARK: - CommentsView + +struct CommentsView: View { + @StateObject private var vm: CommentsViewModel + @EnvironmentObject private var authStore: AuthStore + + init(slug: String) { + _vm = StateObject(wrappedValue: CommentsViewModel(slug: slug)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Section header + sort picker + HStack { + Text("Comments") + .font(.headline) + let total = vm.comments.reduce(0) { $0 + 1 + ($1.replies?.count ?? 0) } + if !vm.isLoading && total > 0 { + Text("(\(total))") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + // Sort picker + if !vm.isLoading && !vm.comments.isEmpty { + Picker("Sort", selection: Binding( + get: { vm.sort }, + set: { vm.setSort($0) } + )) { + ForEach(CommentSortOrder.allCases, id: \.self) { s in + Text(s.label).tag(s) + } + } + .pickerStyle(.segmented) + .frame(width: 120) + } + } + .padding(.horizontal) + .padding(.vertical, 14) + + Divider().padding(.horizontal) + + // Post form + postForm + .padding(.horizontal) + .padding(.vertical, 12) + + Divider().padding(.horizontal) + + // Comment list + if vm.isLoading { + loadingPlaceholder + } else if let err = vm.error { + Text(err) + .font(.subheadline) + .foregroundStyle(.red) + .padding() + } else if vm.comments.isEmpty { + Text("No comments yet. Be the first!") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding() + } else { + ForEach(vm.comments) { comment in + commentThread(comment: comment) + Divider().padding(.leading, 16) + } + } + + Color.clear.frame(height: 16) + } + .task { await vm.load() } + } + + // MARK: - Comment thread (top-level + replies) + + @ViewBuilder + private func commentThread(comment: BookComment) -> some View { + VStack(alignment: .leading, spacing: 0) { + CommentRow( + comment: comment, + myVote: vm.myVotes[comment.id], + isVoting: vm.isVoting(comment.id), + isDeleting: vm.isDeleting(comment.id), + isOwner: authStore.user?.id == comment.userId, + isLoggedIn: authStore.isAuthenticated, + isReplyingTo: vm.replyingToId == comment.id, + avatarUrl: vm.avatarUrls[comment.userId], + onVote: { v in Task { await vm.vote(commentId: comment.id, vote: v) } }, + onDelete: { Task { await vm.deleteComment(commentId: comment.id) } }, + onReply: { + if vm.replyingToId == comment.id { + vm.replyingToId = nil + vm.replyBody = "" + vm.replyError = nil + } else { + vm.replyingToId = comment.id + vm.replyBody = "" + vm.replyError = nil + } + } + ) + + // Inline reply form + if vm.replyingToId == comment.id { + replyForm(parentId: comment.id) + .padding(.leading, 32) + .padding(.trailing, 16) + .padding(.bottom, 8) + } + + // Replies + if let replies = comment.replies, !replies.isEmpty { + VStack(alignment: .leading, spacing: 0) { + ForEach(replies) { reply in + CommentRow( + comment: reply, + myVote: vm.myVotes[reply.id], + isVoting: vm.isVoting(reply.id), + isDeleting: vm.isDeleting(reply.id), + isOwner: authStore.user?.id == reply.userId, + isLoggedIn: authStore.isAuthenticated, + isReplyingTo: false, + isReply: true, + avatarUrl: vm.avatarUrls[reply.userId], + onVote: { v in Task { await vm.vote(commentId: reply.id, vote: v, parentId: comment.id) } }, + onDelete: { Task { await vm.deleteComment(commentId: reply.id, parentId: comment.id) } }, + onReply: nil + ) + if reply.id != replies.last?.id { + Divider().padding(.leading, 48) + } + } + } + .padding(.leading, 24) + .overlay(alignment: .leading) { + Rectangle() + .fill(Color(.systemGray4)) + .frame(width: 2) + .padding(.leading, 16) + .padding(.vertical, 4) + } + } + } + } + + // MARK: - Reply form + + @ViewBuilder + private func replyForm(parentId: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + ZStack(alignment: .topLeading) { + if vm.replyBody.isEmpty { + Text("Write a reply…") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.top, 6) + .padding(.leading, 4) + } + TextEditor(text: $vm.replyBody) + .font(.caption) + .frame(minHeight: 56, maxHeight: 120) + .scrollContentBackground(.hidden) + } + .padding(8) + .background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8)) + + HStack { + let count = vm.replyBody.count + Text("\(count)/2000") + .font(.caption2) + .monospacedDigit() + .foregroundStyle(count > 2000 ? Color.red : Color.secondary) + + Spacer() + + if let err = vm.replyError { + Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1) + } + + Button("Cancel") { + vm.replyingToId = nil + vm.replyBody = "" + vm.replyError = nil + } + .font(.caption) + .foregroundStyle(.secondary) + + Button { + Task { await vm.postReply(parentId: parentId) } + } label: { + if vm.isPostingReply { + ProgressView().controlSize(.mini) + } else { + Text("Reply").fontWeight(.semibold).font(.caption) + } + } + .buttonStyle(.borderedProminent) + .tint(.amber) + .controlSize(.mini) + .disabled(vm.isPostingReply || vm.replyBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.replyBody.count > 2000) + } + } + } + + // MARK: - Post form + + @ViewBuilder + private var postForm: some View { + if authStore.isAuthenticated { + VStack(alignment: .leading, spacing: 8) { + ZStack(alignment: .topLeading) { + if vm.newBody.isEmpty { + Text("Write a comment…") + .font(.subheadline) + .foregroundStyle(.tertiary) + .padding(.top, 8) + .padding(.leading, 4) + } + TextEditor(text: $vm.newBody) + .font(.subheadline) + .frame(minHeight: 72, maxHeight: 160) + .scrollContentBackground(.hidden) + } + .padding(10) + .background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10)) + + HStack { + let count = vm.newBody.count + Text("\(count)/2000") + .font(.caption2) + .monospacedDigit() + .foregroundStyle(count > 2000 ? Color.red : Color.secondary) + + Spacer() + + if let err = vm.postError { + Text(err) + .font(.caption2) + .foregroundStyle(.red) + .lineLimit(1) + } + + Button { + Task { await vm.postComment() } + } label: { + if vm.isPosting { + ProgressView().controlSize(.small) + } else { + Text("Post") + .fontWeight(.semibold) + } + } + .buttonStyle(.borderedProminent) + .tint(.amber) + .controlSize(.small) + .disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000) + } + } + } else { + Text("Log in to leave a comment.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + // MARK: - Loading skeleton + + @ViewBuilder + private var loadingPlaceholder: some View { + VStack(spacing: 12) { + ForEach(0..<3, id: \.self) { _ in + VStack(alignment: .leading, spacing: 8) { + RoundedRectangle(cornerRadius: 4) + .fill(Color(.systemGray5)) + .frame(width: 100, height: 12) + RoundedRectangle(cornerRadius: 4) + .fill(Color(.systemGray6)) + .frame(maxWidth: .infinity) + .frame(height: 12) + RoundedRectangle(cornerRadius: 4) + .fill(Color(.systemGray6)) + .frame(width: 200, height: 12) + } + .padding(.horizontal) + .redacted(reason: .placeholder) + } + } + .padding(.vertical, 12) + } +} + +// MARK: - CommentRow + +private struct CommentRow: View { + let comment: BookComment + let myVote: String? + let isVoting: Bool + let isDeleting: Bool + let isOwner: Bool + let isLoggedIn: Bool + let isReplyingTo: Bool + var isReply: Bool = false + var avatarUrl: String? = nil + let onVote: (String) -> Void + let onDelete: () -> Void + let onReply: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + // Avatar + Username + date + HStack(spacing: 8) { + avatarView + NavigationLink(value: NavDestination.userProfile(comment.username.isEmpty ? "" : comment.username)) { + Text(comment.username.isEmpty ? "Anonymous" : comment.username) + .font(isReply ? .caption.weight(.medium) : .subheadline.weight(.medium)) + .foregroundStyle(.primary) + } + .buttonStyle(.plain) + .disabled(comment.username.isEmpty) + Text("·") + .foregroundStyle(.tertiary) + Text(formattedDate(comment.created)) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + + // Body + Text(comment.body) + .font(isReply ? .caption : .subheadline) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + + // Actions + HStack(spacing: 14) { + // Upvote + Button { onVote("up") } label: { + HStack(spacing: 4) { + Image(systemName: myVote == "up" ? "hand.thumbsup.fill" : "hand.thumbsup") + .font(.caption) + Text("\(comment.upvotes)") + .font(.caption.monospacedDigit()) + } + .foregroundStyle(myVote == "up" ? Color.amber : .secondary) + } + .disabled(isVoting) + + // Downvote + Button { onVote("down") } label: { + HStack(spacing: 4) { + Image(systemName: myVote == "down" ? "hand.thumbsdown.fill" : "hand.thumbsdown") + .font(.caption) + Text("\(comment.downvotes)") + .font(.caption.monospacedDigit()) + } + .foregroundStyle(myVote == "down" ? .red : .secondary) + } + .disabled(isVoting) + + // Reply button (top-level only, logged in) + if let onReply, isLoggedIn { + Button { onReply() } label: { + HStack(spacing: 3) { + Image(systemName: "arrowshape.turn.up.left") + .font(.caption) + Text("Reply") + .font(.caption) + } + .foregroundStyle(isReplyingTo ? Color.amber : .secondary) + } + } + + Spacer() + + // Delete (owner only) + if isOwner { + Button(role: .destructive) { onDelete() } label: { + Image(systemName: "trash") + .font(.caption) + } + .disabled(isDeleting) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .opacity(isDeleting ? 0.5 : 1) + .animation(.easeInOut(duration: 0.15), value: isDeleting) + } + + private var avatarSize: CGFloat { isReply ? 20 : 24 } + + @ViewBuilder + private var avatarView: some View { + if let url = avatarUrl, let imageUrl = URL(string: url) { + AsyncImage(url: imageUrl) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + default: + initialsView + } + } + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + } else { + initialsView + } + } + + private var initialsView: some View { + let name = comment.username.isEmpty ? "?" : comment.username + let letters = String(name.prefix(2)).uppercased() + return ZStack { + Circle() + .fill(Color(.systemGray4)) + .frame(width: avatarSize, height: avatarSize) + Text(letters) + .font(.system(size: avatarSize * 0.42, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + + private func formattedDate(_ iso: String) -> String { + // PocketBase returns "2006-01-02 15:04:05.999Z" format + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: iso) { + let rel = RelativeDateTimeFormatter() + rel.unitsStyle = .abbreviated + return rel.localizedString(for: date, relativeTo: Date()) + } + // Fallback: try space-separated format + let df = DateFormatter() + df.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ" + if let date = df.date(from: iso) { + let rel = RelativeDateTimeFormatter() + rel.unitsStyle = .abbreviated + return rel.localizedString(for: date, relativeTo: Date()) + } + return String(iso.prefix(10)) + } +} diff --git a/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift b/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift new file mode 100644 index 0000000..3f96a86 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift @@ -0,0 +1,567 @@ +import SwiftUI + +// MARK: - Discover View (Browse) +// Serendipity-focused browsing with curated shelves. +// No search bar — use the dedicated Search tab for that. + +struct BrowseView: View { + @StateObject private var vm = DiscoverViewModel() + @State private var showGenreSheet = false + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + OfflineBanner() + + Group { + if vm.isLoading && vm.trending.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMsg = vm.error, vm.trending.isEmpty { + VStack(spacing: 16) { + Image(systemName: "wifi.slash") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text(errorMsg) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal) + Button("Retry") { Task { await vm.load() } } + .buttonStyle(.borderedProminent) + .tint(.amber) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 32) { + // Trending shelf + if !vm.trending.isEmpty { + DiscoverShelf( + title: "Trending Now", + novels: vm.trending, + destination: .browseCategory( + sort: "popular", + genre: "all", + status: "all", + title: "Trending Now" + ) + ) + } + + // Top Rated shelf + if !vm.topRated.isEmpty { + DiscoverShelf( + title: "Top Rated", + novels: vm.topRated, + destination: .browseCategory( + sort: "rating", + genre: "all", + status: "all", + title: "Top Rated" + ) + ) + } + + // Recently Updated shelf + if !vm.recentlyUpdated.isEmpty { + DiscoverShelf( + title: "Recently Updated", + novels: vm.recentlyUpdated, + destination: .browseCategory( + sort: "updated", + genre: "all", + status: "all", + title: "Recently Updated" + ) + ) + } + + // New Releases shelf + if !vm.newReleases.isEmpty { + DiscoverShelf( + title: "New Releases", + novels: vm.newReleases, + destination: .browseCategory( + sort: "new", + genre: "all", + status: "all", + title: "New Releases" + ) + ) + } + + // Categories button — replaces individual genre shelves + CategoriesRow(onTap: { showGenreSheet = true }) + .padding(.horizontal) + + Color.clear.frame(height: 100) + } + .padding(.top, 8) + } + .refreshable { await vm.load() } + } + } + .navigationTitle("Discover") + .appNavigationDestination() + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 16) { + DownloadQueueButton() + AvatarToolbarButton() + } + } + } + .task { await vm.load() } + } + } + .sheet(isPresented: $showGenreSheet) { + GenrePickerSheet() + } + } +} + +// MARK: - Categories row (Apple Books–style single button) + +private struct CategoriesRow: View { + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(Color.amber.opacity(0.15)) + .frame(width: 44, height: 44) + Image(systemName: "square.grid.2x2") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(Color.amber) + } + + VStack(alignment: .leading, spacing: 2) { + Text("Browse by Genre") + .font(.body.weight(.semibold)) + .foregroundStyle(.primary) + Text("Action, Fantasy, Romance & more") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(14) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .buttonStyle(.plain) + } +} + +// MARK: - Genre picker sheet + +private struct GenrePickerSheet: View { + @Environment(\.dismiss) private var dismiss + + private let genres: [(label: String, genre: String, icon: String)] = [ + ("Action", "action", "bolt.fill"), + ("Fantasy", "fantasy", "wand.and.stars"), + ("Romance", "romance", "heart.fill"), + ("Sci-Fi", "sci-fi", "sparkles"), + ("Mystery", "mystery", "magnifyingglass"), + ("Horror", "horror", "moon.fill"), + ("Comedy", "comedy", "face.smiling"), + ("Adventure", "adventure", "map.fill"), + ("Martial Arts", "martial arts", "figure.martial.arts"), + ("Cultivation", "cultivation", "leaf.fill"), + ("Historical", "historical", "building.columns.fill"), + ("Slice of Life", "slice of life", "sun.max.fill"), + ] + + var body: some View { + NavigationStack { + ScrollView { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ], + spacing: 12 + ) { + // "All" tile + NavigationLink(value: NavDestination.browseCategory( + sort: "popular", genre: "all", status: "all", title: "All Novels" + )) { + GenreTile(label: "All Novels", icon: "books.vertical.fill") + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded { dismiss() }) + + ForEach(genres, id: \.genre) { item in + NavigationLink(value: NavDestination.browseCategory( + sort: "popular", + genre: item.genre, + status: "all", + title: item.label + )) { + GenreTile(label: item.label, icon: item.icon) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded { dismiss() }) + } + } + .padding(16) + .padding(.bottom, 20) + } + .navigationTitle("Genres") + .navigationBarTitleDisplayMode(.large) + .appNavigationDestination() + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + .foregroundStyle(Color.amber) + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(20) + } +} + +private struct GenreTile: View { + let label: String + let icon: String + + var body: some View { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Color.amber) + .frame(width: 24) + Text(label) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +// MARK: - Discover Shelf (horizontal scrolling) + +private struct DiscoverShelf: View { + let title: String + let novels: [BrowseNovel] + let destination: NavDestination + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header with "See All" button + HStack(spacing: 10) { + // Amber accent bar — matches ShelfHeader style used on Home and UserProfile + RoundedRectangle(cornerRadius: 2) + .fill(Color.amber) + .frame(width: 3, height: 18) + Text(title) + .font(.title3.bold()) + Spacer() + NavigationLink(value: destination) { + HStack(spacing: 4) { + Text("See All") + .font(.subheadline) + Image(systemName: "chevron.right") + .font(.caption.bold()) + } + .foregroundStyle(.amber) + } + .buttonStyle(.plain) + } + .padding(.horizontal) + + // Horizontal scroll — leading padding aligns cards with header + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 12) { + ForEach(novels) { novel in + NavigationLink(value: NavDestination.book(novel.slug)) { + DiscoverShelfCard(novel: novel) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.vertical, 4) // let shadows breathe + } + } + } +} + +// MARK: - Shelf card (card-style) + +private struct DiscoverShelfCard: View { + let novel: BrowseNovel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .topLeading) { + AsyncCoverImage(url: novel.cover) + .frame(width: 120, height: 173) // 2:3 ratio + .clipShape(RoundedRectangle(cornerRadius: 10)) + .bookCoverZoomSource(slug: novel.slug) + + if !novel.rank.isEmpty { + Text(novel.rank) + .font(.caption2.bold()) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.ultraThinMaterial, in: Capsule()) + .padding(6) + } + } + + VStack(alignment: .leading, spacing: 3) { + Text(novel.title) + .font(.caption.bold()) + .lineLimit(2) + .frame(width: 120, alignment: .leading) + .multilineTextAlignment(.leading) + + if !novel.chapters.isEmpty { + Text(novel.chapters) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: 120, alignment: .leading) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 8) + } + .frame(width: 136) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) + } +} + +// MARK: - Browse Category View (full grid for "See All") + +struct BrowseCategoryView: View { + let sort: String + let genre: String + let status: String + let title: String + + @StateObject private var vm: BrowseViewModel + @State private var showFilters = false + + init(sort: String, genre: String, status: String, title: String) { + self.sort = sort + self.genre = genre + self.status = status + self.title = title + + let viewModel = BrowseViewModel() + viewModel.sort = sort + viewModel.genre = genre + viewModel.status = status + _vm = StateObject(wrappedValue: viewModel) + } + + var body: some View { + Group { + if vm.isLoading && vm.novels.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMsg = vm.error, vm.novels.isEmpty { + VStack(spacing: 16) { + Image(systemName: "wifi.slash") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text(errorMsg) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal) + Button("Retry") { Task { await vm.loadFirstPage() } } + .buttonStyle(.borderedProminent) + .tint(.amber) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 14), + GridItem(.flexible(), spacing: 14) + ], + spacing: 14 + ) { + ForEach(vm.novels) { novel in + NavigationLink(value: NavDestination.book(novel.slug)) { + BrowseCategoryCard(novel: novel) + } + .buttonStyle(.plain) + .onAppear { + // Infinite scroll + if novel.id == vm.novels.last?.id { + Task { await vm.loadNextPage() } + } + } + } + } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 100) + + if vm.isLoading && !vm.novels.isEmpty { + ProgressView() + .padding() + } + } + .refreshable { await vm.loadFirstPage() } + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.large) + .appNavigationDestination() + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showFilters = true + } label: { + Image(systemName: "slider.horizontal.3") + .foregroundStyle(.amber) + } + } + } + .sheet(isPresented: $showFilters) { + BrowseFiltersView(vm: vm) + } + .task { + if vm.novels.isEmpty { + await vm.loadFirstPage() + } + } + } +} + +private struct BrowseCategoryCard: View { + let novel: BrowseNovel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .topLeading) { + AsyncCoverImage(url: novel.cover) + .frame(maxWidth: .infinity) + .aspectRatio(2/3, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .bookCoverZoomSource(slug: novel.slug) + + if !novel.rank.isEmpty { + Text(novel.rank) + .font(.caption2.bold()) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.ultraThinMaterial, in: Capsule()) + .padding(6) + } + } + + VStack(alignment: .leading, spacing: 3) { + Text(novel.title) + .font(.subheadline.bold()) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + if !novel.author.isEmpty { + Text(novel.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if !novel.chapters.isEmpty { + Text(novel.chapters) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) + } +} + +// MARK: - Filters sheet (kept for future "See All" views) + +struct BrowseFiltersView: View { + @ObservedObject var vm: BrowseViewModel + @Environment(\.dismiss) private var dismiss + + let sortOptions = ["popular", "new", "updated", "rating", "rank"] + let genreOptions = ["all", "action", "fantasy", "romance", "sci-fi", "mystery", + "horror", "comedy", "drama", "adventure", "martial arts", + "cultivation", "magic", "supernatural", "historical", "slice of life"] + let statusOptions = ["all", "ongoing", "completed"] + + var body: some View { + NavigationStack { + Form { + Section("Sort") { + ForEach(sortOptions, id: \.self) { opt in + HStack { + Text(opt.capitalized) + Spacer() + if vm.sort == opt { Image(systemName: "checkmark").foregroundStyle(.amber) } + } + .contentShape(Rectangle()) + .onTapGesture { vm.sort = opt; dismiss() } + } + } + Section("Genre") { + ForEach(genreOptions, id: \.self) { opt in + HStack { + Text(opt == "all" ? "All Genres" : opt.capitalized) + Spacer() + if vm.genre == opt { Image(systemName: "checkmark").foregroundStyle(.amber) } + } + .contentShape(Rectangle()) + .onTapGesture { vm.genre = opt; dismiss() } + } + } + Section("Status") { + ForEach(statusOptions, id: \.self) { opt in + HStack { + Text(opt.capitalized) + Spacer() + if vm.status == opt { Image(systemName: "checkmark").foregroundStyle(.amber) } + } + .contentShape(Rectangle()) + .onTapGesture { vm.status = opt; dismiss() } + } + } + } + .navigationTitle("Filters") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + } +} diff --git a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift new file mode 100644 index 0000000..ed18f9d --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift @@ -0,0 +1,1240 @@ +import SwiftUI +import WebKit +import UIKit + +// MARK: - Chapter Reader (Apple Books–style, modern) + +struct ChapterReaderView: View { + let slug: String + let chapterNumber: Int + + @State private var currentChapter: Int + @StateObject private var vm: ChapterReaderViewModel + @StateObject private var readerSettings = ReaderSettingsStore() + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var authStore: AuthStore + + @State private var chromeVisible = true + @State private var showSettingsPanel = false + @State private var showToCSheet = false + + init(slug: String, chapterNumber: Int) { + self.slug = slug + self.chapterNumber = chapterNumber + _currentChapter = State(initialValue: chapterNumber) + _vm = StateObject(wrappedValue: ChapterReaderViewModel(slug: slug, chapter: chapterNumber)) + } + + var body: some View { + ZStack { + // Full-bleed background + readerSettings.settings.theme.backgroundColor + .ignoresSafeArea() + + if vm.isLoading { + ProgressView() + .tint(readerSettings.settings.theme.textColor) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let content = vm.content { + if readerSettings.settings.scrollMode { + ScrollReaderContent( + content: content, + readerSettings: readerSettings, + chromeVisible: $chromeVisible, + onNavigateChapter: navigateToChapter + ) + } else { + PaginatedReaderContent( + content: content, + readerSettings: readerSettings, + chromeVisible: $chromeVisible, + onNavigateChapter: navigateToChapter + ) + } + } else if let errMsg = vm.error { + errorView(errMsg) + } + + // Overlaid chrome (top + bottom) — must NOT ignore safe area so buttons + // stay above the home indicator and below the status bar. + if chromeVisible { + VStack(spacing: 0) { + topChrome + Spacer() + if let content = vm.content { + bottomChrome(content: content) + } + } + .transition(.opacity.animation(.easeInOut(duration: 0.2))) + .ignoresSafeArea(edges: .top) // top chrome extends behind status bar only + } + } + .ignoresSafeArea(edges: .all) + .navigationBarHidden(true) + .toolbar(.hidden, for: .tabBar) + .preferredColorScheme(readerSettings.settings.theme.colorScheme) + .hideMiniPlayer() + .task(id: currentChapter) { await vm.load() } + .sheet(isPresented: $showSettingsPanel) { + ReaderSettingsPanel(store: readerSettings, isPresented: $showSettingsPanel) + .presentationDetents([.height(460)]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(24) + .presentationBackground(.regularMaterial) + } + .sheet(isPresented: $showToCSheet) { + if let content = vm.content { + ChaptersListSheet( + chapters: content.chapters, + currentChapter: currentChapter, + onChapterSelect: { selected in + showToCSheet = false + navigateToChapter(selected) + } + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + } + .onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in + guard let next = note.userInfo?["next"] as? Int, + let autoNext = note.userInfo?["autoNext"] as? Bool, + autoNext, currentChapter == audioPlayer.chapter else { return } + navigateToChapter(next) + } + .onReceive(NotificationCenter.default.publisher(for: .skipToNextChapter)) { note in + guard let next = note.userInfo?["next"] as? Int, + currentChapter == audioPlayer.chapter else { return } + navigateToChapter(next) + } + .onReceive(NotificationCenter.default.publisher(for: .skipToPrevChapter)) { note in + guard let prev = note.userInfo?["prev"] as? Int, + currentChapter == audioPlayer.chapter else { return } + navigateToChapter(prev) + } + } + + // MARK: - Top chrome + + @Environment(\.dismiss) private var dismiss + + private var topChrome: some View { + ZStack(alignment: .bottom) { + Rectangle() + .fill(.ultraThinMaterial) + .colorScheme(readerSettings.settings.theme.colorScheme ?? .light) + .ignoresSafeArea(edges: .top) + + VStack(spacing: 0) { + HStack(spacing: 0) { + // Back + Button { dismiss() } label: { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(readerSettings.settings.theme.textColor) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + + Spacer() + + // Single-line chapter title + if let content = vm.content { + Text(content.chapter.title.strippingTrailingDate()) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85)) + .lineLimit(1) + .frame(maxWidth: 200) + } + + Spacer() + + // ToC + Aa + HStack(spacing: 0) { + Button { showToCSheet = true } label: { + Image(systemName: "list.bullet") + .font(.system(size: 16, weight: .regular)) + .foregroundStyle(readerSettings.settings.theme.textColor) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + showSettingsPanel.toggle() + } + } label: { + Text("Aa") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(readerSettings.settings.theme.textColor) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + } + } + .padding(.horizontal, 4) + .frame(height: 44) + + // Progress bar + if let content = vm.content { + ChapterProgressBar( + currentChapter: content.chapter.number, + totalChapters: content.chapters.last?.number ?? content.chapter.number, + color: accentColor + ) + } + } + } + .fixedSize(horizontal: false, vertical: true) + } + + // MARK: - Bottom chrome + + private func bottomChrome(content: ChapterResponse) -> some View { + HStack(alignment: .center, spacing: 12) { + + // ── Prev chapter ──────────────────────────────────────────────── + if let prev = content.prev { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + navigateToChapter(prev) + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 12, weight: .bold)) + Text("Ch.\(prev)") + .font(.caption.weight(.semibold)) + } + .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7)) + .frame(minWidth: 64) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } else { + Color.clear.frame(width: 64, height: 40) + } + + Spacer(minLength: 0) + + // ── Download ──────────────────────────────────────────────────── + DownloadAudioButton( + slug: slug, + chapter: currentChapter, + voice: audioPlayer.voice, + theme: readerSettings.settings.theme + ) + + // ── Listen / Pause pill ───────────────────────────────────────── + ListenButton( + audioPlayer: audioPlayer, + vm: vm, + authStore: authStore, + theme: readerSettings.settings.theme + ) + + Spacer(minLength: 0) + + // ── Next chapter ──────────────────────────────────────────────── + if let next = content.next { + Button { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + navigateToChapter(next) + } label: { + HStack(spacing: 4) { + Text("Ch.\(next)") + .font(.caption.weight(.semibold)) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .bold)) + } + .foregroundStyle(.white) + .frame(minWidth: 64) + .padding(.vertical, 10) + .background(Capsule().fill(accentColor)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + } else { + Color.clear.frame(width: 64, height: 40) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + Rectangle() + .fill(.ultraThinMaterial) + .colorScheme(readerSettings.settings.theme.colorScheme ?? .light) + .ignoresSafeArea(edges: .bottom) + ) + } + + // MARK: - Helpers + + private var accentColor: Color { + readerSettings.settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) + : .amber + } + + private func errorView(_ msg: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.orange) + Text(msg) + .multilineTextAlignment(.center) + .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7)) + .padding(.horizontal) + Button("Retry") { Task { await vm.load() } } + .buttonStyle(.borderedProminent) + .tint(.amber) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func navigateToChapter(_ chapter: Int) { + vm.switchChapter(to: chapter) + currentChapter = chapter + } +} + +// MARK: - Paginated reader content + +/// Splits chapter HTML into pages and renders them in a horizontal TabView (swipe to turn pages). +/// Edge-swipe on title page (left→right) navigates to previous chapter; +/// edge-swipe on end page (right→left) navigates to next chapter. +private struct PaginatedReaderContent: View { + let content: ChapterResponse + let readerSettings: ReaderSettingsStore + @Binding var chromeVisible: Bool + let onNavigateChapter: (Int) -> Void + + @State private var pages: [AttributedString] = [] + @State private var currentPage: Int = 0 + @State private var geometrySize: CGSize = .zero + @State private var lastPage: Int = 0 + + // Height reserved for top and bottom chrome (approximation — avoids layout passes) + private let topReserve: CGFloat = 80 // nav bar + progress bar + safe area + private let bottomReserve: CGFloat = 64 // single unified toolbar + safe area + + var body: some View { + GeometryReader { geo in + let size = geo.size + TabView(selection: $currentPage) { + ChapterTitlePage( + content: content, + readerSettings: readerSettings + ) + .tag(-1) + .onTapGesture { toggleChrome() } + + ForEach(Array(pages.enumerated()), id: \.offset) { idx, page in + ReaderPage( + text: page, + readerSettings: readerSettings, + pageNumber: idx + 1, + totalPages: pages.count + ) + .tag(idx) + .onTapGesture { toggleChrome() } + } + + ChapterEndPage( + content: content, + readerSettings: readerSettings, + onNavigateChapter: onNavigateChapter + ) + .tag(pages.count) + .onTapGesture { toggleChrome() } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .onChange(of: currentPage) { _, newPage in lastPage = newPage } + .onAppear { + if geometrySize != size { + geometrySize = size + repaginate(size: size) + } + } + .onChange(of: size) { _, newSize in + geometrySize = newSize + repaginate(size: newSize) + } + .onChange(of: readerSettings.settings) { _, _ in + repaginate(size: geometrySize) + } + .onChange(of: content.chapter.number) { _, _ in + currentPage = -1 + repaginate(size: geometrySize) + } + } + .ignoresSafeArea() + .onAppear { currentPage = -1 } + .simultaneousGesture( + DragGesture(minimumDistance: 40, coordinateSpace: .global) + .onEnded { value in + let isHorizontal = abs(value.translation.width) > abs(value.translation.height) * 1.5 + guard isHorizontal else { return } + let swipedRight = value.translation.width > 0 + let swipedLeft = value.translation.width < 0 + if swipedRight && currentPage == -1, let prev = content.prev { + onNavigateChapter(prev) + } else if swipedLeft && currentPage == pages.count, let next = content.next { + onNavigateChapter(next) + } + } + ) + } + + private func toggleChrome() { + withAnimation(.easeInOut(duration: 0.22)) { chromeVisible.toggle() } + } + + private func repaginate(size: CGSize) { + guard size.width > 0, size.height > 0 else { return } + let settings = readerSettings.settings + let hPad: CGFloat = 28 + let textWidth = size.width - hPad * 2 + let textHeight = size.height - topReserve - bottomReserve + + let attributed = HTMLParser.toAttributedString( + html: content.html, + fontSize: settings.fontSize, + lineSpacing: settings.lineSpacing, + fontName: settings.font.fontName, + textColor: settings.theme.textColor + ) + pages = TextPaginator.paginate( + attributed: attributed, + width: textWidth, + height: textHeight, + fontSize: settings.fontSize + ) + if currentPage > pages.count - 1 { + currentPage = max(0, pages.count - 1) + } + } +} + +// MARK: - Scroll mode reader content + +private struct ScrollReaderContent: View { + let content: ChapterResponse + let readerSettings: ReaderSettingsStore + @Binding var chromeVisible: Bool + let onNavigateChapter: (Int) -> Void + + var body: some View { + let settings = readerSettings.settings + let hPad: CGFloat = 24 + let accentColor: Color = settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + // Chapter header + VStack(alignment: .leading, spacing: 10) { + Text(content.book.title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(settings.theme.textColor.opacity(0.45)) + .textCase(.uppercase) + .tracking(1.2) + Rectangle() + .fill(accentColor.opacity(0.6)) + .frame(width: 36, height: 2) + Text(content.chapter.title.strippingTrailingDate()) + .font(.system(size: 22, weight: .bold, design: .serif)) + .foregroundStyle(settings.theme.textColor) + if !content.chapter.dateLabel.isEmpty { + Text(content.chapter.dateLabel) + .font(.caption) + .foregroundStyle(settings.theme.textColor.opacity(0.4)) + } + } + .padding(.horizontal, hPad) + .padding(.top, 20) + .padding(.bottom, 20) + + // Body + let attributed = HTMLParser.toAttributedString( + html: content.html, + fontSize: settings.fontSize, + lineSpacing: settings.lineSpacing, + fontName: settings.font.fontName, + textColor: settings.theme.textColor + ) + Text(attributed) + .padding(.horizontal, hPad) + + // Next chapter footer + VStack(spacing: 16) { + Divider().padding(.horizontal, hPad) + if let next = content.next { + Button { onNavigateChapter(next) } label: { + HStack { + Text("Next Chapter") + .fontWeight(.semibold) + Image(systemName: "arrow.right") + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(Capsule().fill(accentColor)) + } + .buttonStyle(.plain) + .padding(.horizontal, hPad) + } + } + .padding(.vertical, 24) + .padding(.bottom, 80) + } + } + // Offset content below the top chrome without padding (safeAreaInset) + .safeAreaInset(edge: .top) { Color.clear.frame(height: 52) } + .background(settings.theme.backgroundColor) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.easeInOut(duration: 0.22)) { chromeVisible.toggle() } + } + } +} + +// MARK: - Individual reader page + +private struct ReaderPage: View { + let text: AttributedString + let readerSettings: ReaderSettingsStore + let pageNumber: Int + let totalPages: Int + + var body: some View { + let settings = readerSettings.settings + let hPad: CGFloat = 28 + let topPad: CGFloat = 80 // visual breathing room below top chrome + let bottomPad: CGFloat = 56 // visual breathing room above bottom chrome + + GeometryReader { geo in + ZStack(alignment: .bottom) { + Text(text) + .frame(width: geo.size.width - hPad * 2, alignment: .topLeading) + .frame(maxHeight: .infinity, alignment: .top) + .padding(.horizontal, hPad) + .padding(.top, topPad) + .padding(.bottom, bottomPad) + .frame(maxWidth: .infinity) + + // Page indicator: "3 of 47" centered at bottom + Text("\(pageNumber) of \(totalPages)") + .font(.system(size: 11, weight: .regular).monospacedDigit()) + .foregroundStyle(settings.theme.textColor.opacity(0.3)) + .padding(.bottom, bottomPad - 24) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(settings.theme.backgroundColor) + } + } +} + +// MARK: - Chapter title page + +private struct ChapterTitlePage: View { + let content: ChapterResponse + let readerSettings: ReaderSettingsStore + + private var totalChapters: Int { + content.chapters.last?.number ?? content.chapter.number + } + + private var progressPercent: Int { + guard totalChapters > 1 else { return 100 } + return Int((Double(content.chapter.number) / Double(totalChapters)) * 100) + } + + private var accentColor: Color { + readerSettings.settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + } + + var body: some View { + let settings = readerSettings.settings + GeometryReader { geo in + VStack(alignment: .leading, spacing: 0) { + Spacer() + + VStack(alignment: .leading, spacing: 14) { + // Book name pill + Text(content.book.title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(settings.theme.textColor.opacity(0.45)) + .textCase(.uppercase) + .tracking(1.4) + .lineLimit(2) + + // Accent rule + Rectangle() + .fill(accentColor) + .frame(width: 36, height: 2) + .clipShape(Capsule()) + + // Chapter title — large serif + Text(content.chapter.title.strippingTrailingDate()) + .font(.system(size: min(32, geo.size.width / 10.5), weight: .bold, design: .serif)) + .foregroundStyle(settings.theme.textColor) + .fixedSize(horizontal: false, vertical: true) + .lineSpacing(4) + + // Meta row + HStack(spacing: 8) { + if !content.chapter.dateLabel.isEmpty { + Text(content.chapter.dateLabel) + .font(.caption) + .foregroundStyle(settings.theme.textColor.opacity(0.4)) + } + if totalChapters > 1 { + if !content.chapter.dateLabel.isEmpty { + Circle() + .fill(settings.theme.textColor.opacity(0.25)) + .frame(width: 3, height: 3) + } + Text("\(progressPercent)% through") + .font(.caption.weight(.medium)) + .foregroundStyle(accentColor.opacity(0.85)) + } + } + } + .padding(.horizontal, 36) + + Spacer() + Spacer() + + // Swipe hint — uses phaseAnimator for continuous subtle motion + HStack(spacing: 6) { + Image(systemName: "arrow.right") + .font(.caption2.weight(.semibold)) + Text("Swipe to read") + .font(.caption2) + } + .foregroundStyle(settings.theme.textColor.opacity(0.5)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.bottom, 96) + .phaseAnimator([false, true]) { content, phase in + content + .offset(x: phase ? 4 : -2) + .opacity(phase ? 0.55 : 0.15) + } animation: { phase in + .easeInOut(duration: 0.9) + } + .onAppear {} + } + .frame(maxWidth: .infinity) + .background(settings.theme.backgroundColor) + } + } +} + +// MARK: - Chapter end page + +private struct ChapterEndPage: View { + let content: ChapterResponse + let readerSettings: ReaderSettingsStore + let onNavigateChapter: (Int) -> Void + + @State private var appeared = false + + private var accentColor: Color { + readerSettings.settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + } + + var body: some View { + let settings = readerSettings.settings + VStack(spacing: 32) { + Spacer() + + VStack(spacing: 20) { + // Layered ring + checkmark + ZStack { + Circle() + .fill(accentColor.opacity(0.07)) + .frame(width: 96, height: 96) + Circle() + .fill(accentColor.opacity(0.14)) + .frame(width: 72, height: 72) + Image(systemName: "checkmark") + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(accentColor) + .symbolEffect(.bounce, value: appeared) + } + .scaleEffect(appeared ? 1 : 0.7) + .opacity(appeared ? 1 : 0) + .animation(.spring(response: 0.5, dampingFraction: 0.65).delay(0.05), value: appeared) + + VStack(spacing: 6) { + Text("Chapter \(content.chapter.number)") + .font(.caption.weight(.semibold)) + .foregroundStyle(accentColor) + .textCase(.uppercase) + .tracking(1.2) + + Text("Complete") + .font(.title2.bold()) + .foregroundStyle(settings.theme.textColor) + + if content.next == nil { + Text("You've reached the latest chapter") + .font(.subheadline) + .foregroundStyle(settings.theme.textColor.opacity(0.4)) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 10) + .animation(.easeOut(duration: 0.35).delay(0.15), value: appeared) + } + + if let next = content.next { + Button { onNavigateChapter(next) } label: { + HStack(spacing: 8) { + Text("Chapter \(next)") + .fontWeight(.semibold) + Image(systemName: "arrow.right") + .font(.system(size: 14, weight: .semibold)) + } + .foregroundStyle(.white) + .frame(height: 52) + .frame(maxWidth: 240) + .background(Capsule().fill(accentColor)) + } + .buttonStyle(.plain) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 12) + .animation(.easeOut(duration: 0.35).delay(0.25), value: appeared) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(settings.theme.backgroundColor) + .onAppear { appeared = true } + .onDisappear { appeared = false } + } +} + +// MARK: - Chapter progress bar + +private struct ChapterProgressBar: View { + let currentChapter: Int + let totalChapters: Int + let color: Color + + private var progress: Double { + guard totalChapters > 1 else { return 1.0 } + return Double(currentChapter) / Double(totalChapters) + } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Rectangle().fill(color.opacity(0.10)) + Rectangle() + .fill( + LinearGradient( + colors: [color.opacity(0.7), color], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: geo.size.width * progress) + .animation(.spring(response: 0.5, dampingFraction: 0.85), value: progress) + } + } + .frame(height: 3) + } +} + +// MARK: - Listen button (bottom chrome) + +private struct ListenButton: View { + @ObservedObject var audioPlayer: AudioPlayerService + @ObservedObject var vm: ChapterReaderViewModel + @ObservedObject var authStore: AuthStore + let theme: ReaderTheme + + private var isActive: Bool { + audioPlayer.isActive && audioPlayer.slug == vm.slug && audioPlayer.chapter == vm.chapter + } + + private var isGenerating: Bool { + audioPlayer.status == .generating && audioPlayer.slug == vm.slug && audioPlayer.chapter == vm.chapter + } + + private var accentColor: Color { + theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + } + + var body: some View { + Button { + vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings) + } label: { + HStack(spacing: 7) { + if isGenerating { + ProgressView() + .scaleEffect(0.75) + .tint(isActive ? .white : accentColor) + } else { + Image(systemName: isActive ? "waveform" : "headphones") + .font(.system(size: 15, weight: .semibold)) + .symbolEffect(.variableColor.cumulative, isActive: isActive) + } + Text(isGenerating ? "Generating…" : (isActive ? "Listening" : "Listen")) + .font(.subheadline.weight(.semibold)) + } + .foregroundStyle(isActive ? .white : accentColor) + .padding(.horizontal, 18) + .padding(.vertical, 10) + .background( + Capsule() + .fill(isActive ? accentColor : accentColor.opacity(0.13)) + ) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isActive) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isGenerating) + } +} + +// MARK: - Reading settings panel + +struct ReaderSettingsPanel: View { + @ObservedObject var store: ReaderSettingsStore + @Binding var isPresented: Bool + + var body: some View { + VStack(spacing: 0) { + // Handle + Capsule() + .fill(Color(.systemGray4)) + .frame(width: 36, height: 5) + .padding(.top, 10) + .padding(.bottom, 18) + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 22) { + + // ── Font size ────────────────────────────────────────── + VStack(alignment: .leading, spacing: 10) { + SectionLabel("Font Size") + HStack(spacing: 0) { + Button { adjustFontSize(-1) } label: { + Text("A") + .font(.system(size: 13, weight: .regular)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + Slider( + value: Binding( + get: { store.settings.fontSize }, + set: { v in + var s = store.settings; s.fontSize = v; store.update(s) + } + ), + in: 12...26, step: 1 + ) + .tint(.amber) + .padding(.horizontal, 8) + Button { adjustFontSize(1) } label: { + Text("A") + .font(.system(size: 21, weight: .semibold)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + } + } + + settingsDivider + + // ── Font family ──────────────────────────────────────── + VStack(alignment: .leading, spacing: 10) { + SectionLabel("Font") + HStack(spacing: 8) { + ForEach(ReaderFont.allCases, id: \.self) { font in + FontChip(font: font, isSelected: store.settings.font == font) { + var s = store.settings; s.font = font; store.update(s) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + } + } + } + + settingsDivider + + // ── Theme ────────────────────────────────────────────── + VStack(alignment: .leading, spacing: 10) { + SectionLabel("Theme") + HStack(spacing: 8) { + ForEach(ReaderTheme.allCases, id: \.self) { theme in + ThemeChip(theme: theme, isSelected: store.settings.theme == theme) { + var s = store.settings; s.theme = theme; store.update(s) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + } + } + } + + settingsDivider + + // ── Line spacing ─────────────────────────────────────── + VStack(alignment: .leading, spacing: 10) { + SectionLabel("Line Spacing") + HStack(spacing: 8) { + Image(systemName: "text.alignleft") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .frame(width: 28) + Slider( + value: Binding( + get: { store.settings.lineSpacing }, + set: { v in var s = store.settings; s.lineSpacing = v; store.update(s) } + ), + in: 1.2...2.4, step: 0.1 + ) + .tint(.amber) + Image(systemName: "text.alignleft") + .font(.system(size: 20)) + .foregroundStyle(.secondary) + .frame(width: 28) + } + } + + settingsDivider + + // ── Scroll vs Pages ──────────────────────────────────── + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(store.settings.scrollMode ? "Scroll" : "Pages") + .font(.subheadline.weight(.medium)) + Text(store.settings.scrollMode + ? "Continuous vertical scroll" + : "Swipe horizontally between pages") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Toggle("", isOn: Binding( + get: { store.settings.scrollMode }, + set: { v in + var s = store.settings; s.scrollMode = v; store.update(s) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + )) + .tint(.amber) + .labelsHidden() + } + + Color.clear.frame(height: 8) + } + .padding(.horizontal, 20) + } + } + } + + private var settingsDivider: some View { + Divider().padding(.horizontal, 4) + } + + private func adjustFontSize(_ delta: CGFloat) { + var s = store.settings + s.fontSize = max(12, min(26, s.fontSize + delta)) + store.update(s) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } +} + +private struct SectionLabel: View { + let title: String + init(_ title: String) { self.title = title } + + var body: some View { + Text(title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.8) + } +} + +private struct FontChip: View { + let font: ReaderFont + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(font.rawValue) + .font(font.fontName.map { Font.custom($0, size: 15) } ?? .system(size: 15)) + .frame(maxWidth: .infinity) + .frame(height: 46) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? Color.amber : Color.clear, lineWidth: 1.5) + ) + ) + .foregroundStyle(isSelected ? .amber : .primary) + .scaleEffect(isSelected ? 1.03 : 1.0) + } + .buttonStyle(.plain) + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected) + } +} + +private struct ThemeChip: View { + let theme: ReaderTheme + let isSelected: Bool + let action: () -> Void + + private var label: String { + switch theme { + case .white: return "White" + case .sepia: return "Sepia" + case .night: return "Night" + } + } + + var body: some View { + Button(action: action) { + Text(label) + .font(.subheadline.weight(isSelected ? .semibold : .regular)) + .frame(maxWidth: .infinity) + .frame(height: 46) + .background(theme.backgroundColor) + .foregroundStyle(theme.textColor) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? Color.amber : Color(.systemGray4), + lineWidth: isSelected ? 2 : 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .scaleEffect(isSelected ? 1.03 : 1.0) + } + .buttonStyle(.plain) + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected) + } +} + +// MARK: - ReaderSettingsStore + +final class ReaderSettingsStore: ObservableObject { + @Published private(set) var settings: ReaderSettings + + init() { settings = ReaderSettings.load() } + + func update(_ new: ReaderSettings) { + settings = new + new.save() + } +} + +// MARK: - HTML → AttributedString parser + +enum HTMLParser { + /// Strips the duplicated chapter-header block novelfire embeds at the top of the HTML body. + static func stripLeadingChapterHeader(from html: String) -> String { + var result = html + for _ in 0..<3 { + let pattern = #"^(\s*]*>)(.*?)(

)"# + guard let regex = try? NSRegularExpression( + pattern: pattern, + options: [.dotMatchesLineSeparators, .caseInsensitive] + ) else { break } + + guard let match = regex.firstMatch( + in: result, + range: NSRange(result.startIndex..., in: result) + ) else { break } + + let innerRange = match.range(at: 2) + guard innerRange.location != NSNotFound, + let swiftRange = Range(innerRange, in: result) else { break } + + let inner = String(result[swiftRange]) + let plain = inner + .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + let isHeaderLine = plain.range( + of: #"^\d*\s*[Cc]hapter\s+\d+"#, + options: .regularExpression + ) != nil + guard isHeaderLine else { break } + + let fullMatchRange = match.range(at: 0) + guard let swiftFullRange = Range(fullMatchRange, in: result) else { break } + result.removeSubrange(swiftFullRange) + } + return result + } + + static func toAttributedString( + html: String, + fontSize: CGFloat, + lineSpacing: CGFloat, + fontName: String?, + textColor: Color + ) -> AttributedString { + let uiFont: UIFont + if let name = fontName, let custom = UIFont(name: name, size: fontSize) { + uiFont = custom + } else { + uiFont = UIFont.systemFont(ofSize: fontSize) + } + + let uiColor = UIColor(textColor) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = (lineSpacing - 1.0) * fontSize + paragraphStyle.paragraphSpacing = fontSize * 0.7 + + let cleanedHtml = stripLeadingChapterHeader(from: html) + let htmlData = Data(cleanedHtml.utf8) + let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue + ] + + let nsAttr: NSMutableAttributedString + if let parsed = try? NSMutableAttributedString(data: htmlData, options: options, documentAttributes: nil) { + nsAttr = parsed + } else { + let plain = cleanedHtml.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + nsAttr = NSMutableAttributedString(string: plain) + } + + let fullRange = NSRange(location: 0, length: nsAttr.length) + nsAttr.addAttribute(.font, value: uiFont, range: fullRange) + nsAttr.addAttribute(.foregroundColor, value: uiColor, range: fullRange) + nsAttr.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange) + + return (try? AttributedString(nsAttr, including: \.uiKit)) ?? AttributedString(nsAttr.string) + } +} + +// MARK: - Text paginator + +enum TextPaginator { + static func paginate( + attributed: AttributedString, + width: CGFloat, + height: CGFloat, + fontSize: CGFloat + ) -> [AttributedString] { + guard width > 0, height > 0 else { return [attributed] } + + let nsAttr = NSAttributedString(attributed) + guard nsAttr.length > 0 else { return [] } + + let framesetter = CTFramesetterCreateWithAttributedString(nsAttr) + let path = CGPath(rect: CGRect(x: 0, y: 0, width: width, height: height), transform: nil) + + var pages: [AttributedString] = [] + var startIndex = 0 + let totalLength = nsAttr.length + var emergencyBreak = 0 + + while startIndex < totalLength { + emergencyBreak += 1 + if emergencyBreak > 2000 { break } + + let range = CFRange(location: startIndex, length: totalLength - startIndex) + let frame = CTFramesetterCreateFrame(framesetter, range, path, nil) + let visibleRange = CTFrameGetVisibleStringRange(frame) + + let pageLength = visibleRange.length > 0 ? visibleRange.length : max(1, totalLength - startIndex) + let endIndex = min(startIndex + pageLength, totalLength) + + let pageRange = NSRange(location: startIndex, length: endIndex - startIndex) + let pageAttr = nsAttr.attributedSubstring(from: pageRange) + if let pageAS = try? AttributedString(pageAttr, including: \.uiKit) { + pages.append(pageAS) + } + + if visibleRange.length <= 0 { break } + startIndex = endIndex + } + + return pages.isEmpty ? [attributed] : pages + } +} + +// MARK: - Reverse label style (kept for compatibility) + +struct ReverseLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack { + configuration.title + configuration.icon + } + } +} + +// MARK: - HTMLContentView (kept for potential fallback use) + +struct HTMLContentView: UIViewRepresentable { + let html: String + @Binding var height: CGFloat + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeUIView(context: Context) -> WKWebView { + let wv = WKWebView() + wv.scrollView.isScrollEnabled = false + wv.isOpaque = false + wv.backgroundColor = .clear + wv.scrollView.backgroundColor = .clear + wv.navigationDelegate = context.coordinator + return wv + } + + func updateUIView(_ uiView: WKWebView, context: Context) { + let isDark = UITraitCollection.current.userInterfaceStyle == .dark + let textColor = isDark ? "#e5e5e5" : "#1a1a1a" + let css = """ + body { + font-family: -apple-system, Georgia, serif; + font-size: 17px; + line-height: 1.7; + color: \(textColor); + background: transparent; + margin: 0; padding: 0; + word-break: break-word; + } + p { margin: 0 0 1em 0; } + """ + let wrapped = "\(html)" + uiView.loadHTMLString(wrapped, baseURL: nil) + } + + class Coordinator: NSObject, WKNavigationDelegate { + var parent: HTMLContentView + init(_ parent: HTMLContentView) { self.parent = parent } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + webView.evaluateJavaScript("document.body.scrollHeight") { result, _ in + DispatchQueue.main.async { + if let h = result as? CGFloat, h > 0 { + self.parent.height = h + } else if let h = result as? Double, h > 0 { + self.parent.height = CGFloat(h) + } + } + } + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/ChapterReader/DownloadAudioButton.swift b/ios/LibNovel/LibNovel/Views/ChapterReader/DownloadAudioButton.swift new file mode 100644 index 0000000..9a609fd --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/ChapterReader/DownloadAudioButton.swift @@ -0,0 +1,156 @@ +import SwiftUI + +// MARK: - Download Audio Button +// Shows download status and allows users to download/delete offline audio. +// Uses symbolEffect + spring animations for a modern, tactile feel. + +struct DownloadAudioButton: View { + let slug: String + let chapter: Int + let voice: String + let theme: ReaderTheme + + @StateObject private var downloadService = AudioDownloadService.shared + @State private var showDownloadMenu = false + @State private var bounceDownload = false + + private var downloadKey: String { + AudioDownloadService.shared.makeKey(slug: slug, chapter: chapter, voice: voice) + } + + private var isDownloaded: Bool { + downloadService.isDownloaded(slug: slug, chapter: chapter, voice: voice) + } + + private var downloadProgress: DownloadProgress? { + downloadService.downloads[downloadKey] + } + + private var accentColor: Color { + theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + } + + var body: some View { + Button { + showDownloadMenu = true + } label: { + ZStack { + // Background pill + Circle() + .fill(backgroundFillColor) + .frame(width: 44, height: 44) + + stateIcon + } + } + .buttonStyle(.plain) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isDownloaded) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: downloadProgress?.status.isDownloading) + .confirmationDialog("Audio Download", isPresented: $showDownloadMenu) { + if isDownloaded { + Button("Delete Download", role: .destructive) { + Task { + try? await downloadService.deleteDownload(slug: slug, chapter: chapter, voice: voice) + } + } + } else if let progress = downloadProgress, case .downloading = progress.status { + Button("Cancel Download", role: .destructive) { + downloadService.cancelDownload(slug: slug, chapter: chapter, voice: voice) + } + } else { + Button("Download for Offline") { + Task { + try? await downloadService.download(slug: slug, chapter: chapter, voice: voice) + } + withAnimation(.spring(response: 0.4, dampingFraction: 0.5)) { bounceDownload.toggle() } + } + } + Button("Cancel", role: .cancel) {} + } message: { + if isDownloaded { + Text("This chapter's audio is downloaded for offline listening.") + } else if let progress = downloadProgress, case .downloading = progress.status { + Text("Downloading… \(Int(progress.progress * 100))%") + } else { + Text("Download this chapter's audio to listen offline without internet connection.") + } + } + } + + // MARK: - Background + + private var backgroundFillColor: Color { + if isDownloaded { + return Color.green.opacity(0.15) + } else if let progress = downloadProgress, case .downloading = progress.status { + return accentColor.opacity(0.1) + } else if let progress = downloadProgress, case .failed = progress.status { + return Color.red.opacity(0.12) + } else { + return theme.textColor.opacity(0.07) + } + } + + // MARK: - Icon + + @ViewBuilder + private var stateIcon: some View { + if isDownloaded { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 22)) + .foregroundStyle(.green) + .symbolEffect(.bounce, value: isDownloaded) + .transition(.scale.combined(with: .opacity)) + + } else if let progress = downloadProgress { + switch progress.status { + case .downloading: + ZStack { + // Track ring + Circle() + .stroke(accentColor.opacity(0.18), lineWidth: 2.5) + // Progress arc + Circle() + .trim(from: 0, to: progress.progress) + .stroke( + accentColor, + style: StrokeStyle(lineWidth: 2.5, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.2), value: progress.progress) + // Down arrow + Image(systemName: "arrow.down") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(accentColor) + } + .frame(width: 26, height: 26) + .transition(.scale.combined(with: .opacity)) + + case .failed: + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 22)) + .foregroundStyle(.red) + .symbolEffect(.pulse) + .transition(.scale.combined(with: .opacity)) + + case .completed: + EmptyView() + } + + } else { + // Idle — not yet downloaded + Image(systemName: "arrow.down.circle") + .font(.system(size: 22)) + .foregroundStyle(theme.textColor.opacity(0.55)) + .symbolEffect(.bounce, value: bounceDownload) + .transition(.scale.combined(with: .opacity)) + } + } +} + +private extension DownloadStatus { + var isDownloading: Bool { + if case .downloading = self { return true } + return false + } +} diff --git a/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift b/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift new file mode 100644 index 0000000..264a0f1 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift @@ -0,0 +1,164 @@ +import SwiftUI +import Kingfisher + +// MARK: - Empty state placeholder used across all screens + +struct EmptyStateView: View { + let icon: String + let title: String + let message: String + + var body: some View { + VStack(spacing: 14) { + Image(systemName: icon) + .font(.system(size: 48)) + .foregroundStyle(.tertiary) + Text(title) + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + } +} + +// MARK: - Cover image card reused across screens + +struct BookCard: View { + let book: Book + var body: some View { + VStack(alignment: .leading, spacing: 6) { + AsyncCoverImage(url: book.cover) + .frame(height: 200) + .clipShape(RoundedRectangle(cornerRadius: 10)) + Text(book.title) + .font(.caption.bold()) + .lineLimit(2) + Text(book.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } +} + +// MARK: - Async cover image with disk/memory caching via Kingfisher + +struct AsyncCoverImage: View { + let url: String + /// When true the placeholder is a plain colour fill — used for blurred hero backgrounds + /// so the rounded-rect loading indicator doesn't bleed through. + var isBackground: Bool = false + + var body: some View { + KFImage(URL(string: url)) + .resizable() + .placeholder { + if isBackground { + Color(.systemGray6) + } else { + RoundedRectangle(cornerRadius: 10) + .fill(Color(.systemGray5)) + .overlay(Image(systemName: "book.closed").foregroundStyle(.secondary)) + } + } + .scaledToFill() + } +} + +// MARK: - Tag chip + +struct TagChip: View { + let label: String + var body: some View { + Text(label) + .font(.caption2.bold()) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(.systemGray5), in: Capsule()) + } +} + +// MARK: - Unified chip button (filter/sort chips across all screens) +// +// .filled → amber background when selected (genre filter chips in Library) +// .outlined → amber border + tint when selected, grey background (sort chips, browse filter chips) + +enum ChipButtonStyle { case filled, outlined } + +struct ChipButton: View { + let label: String + let isSelected: Bool + var style: ChipButtonStyle = .filled + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(label) + .font(chipFont) + .padding(.horizontal, chipHPad) + .padding(.vertical, 6) + .background(background) + .foregroundStyle(foregroundColor) + .overlay(border) + } + .buttonStyle(.plain) + } + + private var chipFont: Font { + switch style { + case .filled: return .caption.weight(isSelected ? .semibold : .regular) + case .outlined: return .subheadline.weight(isSelected ? .semibold : .regular) + } + } + + private var chipHPad: CGFloat { style == .outlined ? 14 : 12 } + + @ViewBuilder + private var background: some View { + switch style { + case .filled: + Capsule().fill(isSelected ? Color.amber : Color(.systemGray5)) + case .outlined: + Capsule() + .fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6)) + .overlay(Capsule().stroke(isSelected ? Color.amber : .clear, lineWidth: 1.5)) + } + } + + private var foregroundColor: Color { + switch style { + case .filled: return isSelected ? .white : .primary + case .outlined: return isSelected ? .amber : .primary + } + } + + @ViewBuilder + private var border: some View { + // outlined style already has its border baked into `background` + EmptyView() + } +} + +// MARK: - Shelf header (amber accent bar + title) +// Used by HomeView, UserProfileView, BrowseView's DiscoverShelf, and any future shelf screen. +// Call sites that need trailing content (e.g. a "See All" NavigationLink) wrap this in an HStack. + +struct ShelfHeader: View { + let title: String + + var body: some View { + HStack(spacing: 10) { + // 3-pt amber accent bar — the brand visual anchor for all shelf titles + RoundedRectangle(cornerRadius: 2) + .fill(Color.amber) + .frame(width: 3, height: 18) + Text(title) + .font(.title3.bold()) + } + .padding(.horizontal) + .padding(.bottom, 10) + } +} diff --git a/ios/LibNovel/LibNovel/Views/Components/OfflineBanner.swift b/ios/LibNovel/LibNovel/Views/Components/OfflineBanner.swift new file mode 100644 index 0000000..b440baa --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Components/OfflineBanner.swift @@ -0,0 +1,32 @@ +import SwiftUI + +// MARK: - Offline Banner +// Subtle banner shown at top of screen when network is unavailable + +struct OfflineBanner: View { + @EnvironmentObject var networkMonitor: NetworkMonitor + + var body: some View { + if !networkMonitor.isConnected { + HStack(spacing: 8) { + Image(systemName: "wifi.slash") + .font(.caption) + Text("You're offline") + .font(.subheadline.weight(.medium)) + Spacer() + Text("Showing cached content") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.15)) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.orange.opacity(0.3)) + .frame(height: 1) + } + .transition(.move(edge: .top).combined(with: .opacity)) + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/Downloads/DownloadQueueButton.swift b/ios/LibNovel/LibNovel/Views/Downloads/DownloadQueueButton.swift new file mode 100644 index 0000000..cbb3c4a --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Downloads/DownloadQueueButton.swift @@ -0,0 +1,340 @@ +import SwiftUI + +// MARK: - Download Queue Toolbar Button +// Compact toolbar button that shows active download status and opens queue management sheet. +// Shows: +// - Download icon with badge count when downloads are active +// - Progress ring around icon +// - Taps opens DownloadQueueSheet for management + +struct DownloadQueueButton: View { + @StateObject private var downloadService = AudioDownloadService.shared + @State private var showQueue = false + + private var activeDownloads: [DownloadProgress] { + downloadService.downloads.values.filter { $0.status == .downloading } + } + + private var hasActiveDownloads: Bool { + !activeDownloads.isEmpty + } + + private var averageProgress: Double { + guard !activeDownloads.isEmpty else { return 0 } + let total = activeDownloads.reduce(0.0) { $0 + $1.progress } + return total / Double(activeDownloads.count) + } + + var body: some View { + Button { + showQueue = true + } label: { + ZStack { + // Progress ring (only shown when downloading) + if hasActiveDownloads { + Circle() + .stroke(Color.amber.opacity(0.3), lineWidth: 2) + .frame(width: 30, height: 30) + + Circle() + .trim(from: 0, to: averageProgress) + .stroke(Color.amber, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .frame(width: 30, height: 30) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.3), value: averageProgress) + } + + // Download icon + Image(systemName: hasActiveDownloads ? "arrow.down.circle.fill" : "arrow.down.circle") + .font(.system(size: 22)) + .foregroundStyle(hasActiveDownloads ? .amber : .secondary) + .symbolRenderingMode(.hierarchical) + + // Badge count (top-right corner) + if activeDownloads.count > 0 { + VStack { + HStack { + Spacer() + Text("\(activeDownloads.count)") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white) + .padding(3) + .frame(minWidth: 16) + .background(Circle().fill(Color.red)) + .offset(x: 6, y: -6) + } + Spacer() + } + .frame(width: 30, height: 30) + } + } + } + .opacity(hasActiveDownloads || downloadService.downloadedChapters.count > 0 ? 1 : 0.6) + .sheet(isPresented: $showQueue) { + DownloadQueueSheet() + } + } +} + +// MARK: - Download Queue Management Sheet +// Bottom sheet showing active downloads and quick management options + +struct DownloadQueueSheet: View { + @StateObject private var downloadService = AudioDownloadService.shared + @Environment(\.dismiss) private var dismiss + + private var activeDownloads: [(key: String, value: DownloadProgress)] { + downloadService.downloads + .filter { $0.value.status == .downloading } + .sorted { $0.key < $1.key } + } + + private var failedDownloads: [(key: String, value: DownloadProgress)] { + downloadService.downloads.compactMap { key, value in + if case .failed = value.status { + return (key, value) + } + return nil + } + .sorted { $0.key < $1.key } + } + + private var totalDownloaded: Int { + downloadService.downloadedChapters.count + } + + var body: some View { + NavigationStack { + Group { + if activeDownloads.isEmpty && failedDownloads.isEmpty && totalDownloaded == 0 { + emptyState + } else { + List { + // Active downloads section + if !activeDownloads.isEmpty { + Section { + ForEach(activeDownloads, id: \.key) { key, progress in + ActiveDownloadRow(progress: progress) + } + } header: { + HStack { + Text("Downloading") + Spacer() + Text("\(activeDownloads.count)") + .foregroundStyle(.secondary) + } + } + } + + // Failed downloads section + if !failedDownloads.isEmpty { + Section("Failed") { + ForEach(failedDownloads, id: \.key) { key, progress in + FailedDownloadRow(progress: progress, key: key) + } + } + } + + // Quick stats section + Section { + NavigationLink { + DownloadsView() + } label: { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Downloaded Chapters") + Spacer() + Text("\(totalDownloaded)") + .foregroundStyle(.secondary) + } + } + + HStack { + Image(systemName: "internaldrive") + .foregroundStyle(.amber) + Text("Storage Used") + Spacer() + Text(storageUsedFormatted) + .foregroundStyle(.secondary) + } + } + + // Cancel all option (only show if there are active downloads) + if !activeDownloads.isEmpty { + Section { + Button(role: .destructive) { + activeDownloads.forEach { key, progress in + downloadService.cancelDownload( + slug: progress.slug, + chapter: progress.chapter, + voice: progress.voice + ) + } + } label: { + HStack { + Spacer() + Text("Cancel All Downloads") + .font(.subheadline.bold()) + Spacer() + } + } + } + } + } + } + } + .navigationTitle("Download Queue") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() + } + .foregroundStyle(.amber) + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + + // MARK: - Empty State + + @ViewBuilder + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "arrow.down.circle") + .font(.system(size: 56)) + .foregroundStyle(.secondary.opacity(0.5)) + Text("No Active Downloads") + .font(.title2.bold()) + .foregroundStyle(.primary) + Text("Audio chapters you download will appear here") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Helpers + + private var storageUsedFormatted: String { + let bytes = downloadService.getTotalStorageUsed() + return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } +} + +// MARK: - Active Download Row + +private struct ActiveDownloadRow: View { + let progress: DownloadProgress + @StateObject private var downloadService = AudioDownloadService.shared + + var body: some View { + HStack(spacing: 12) { + // Book/Chapter info + VStack(alignment: .leading, spacing: 4) { + Text(formatSlug(progress.slug)) + .font(.subheadline.bold()) + .lineLimit(1) + Text("Chapter \(progress.chapter)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + // Progress indicator + VStack(alignment: .trailing, spacing: 4) { + Text("\(Int(progress.progress * 100))%") + .font(.caption.bold()) + .foregroundStyle(.amber) + .monospacedDigit() + + ProgressView(value: progress.progress) + .frame(width: 60) + .tint(.amber) + } + + // Cancel button + Button { + downloadService.cancelDownload( + slug: progress.slug, + chapter: progress.chapter, + voice: progress.voice + ) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 20)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.vertical, 4) + } + + private func formatSlug(_ slug: String) -> String { + // Convert slug to readable title (e.g., "my-book-title" -> "My Book Title") + slug.split(separator: "-") + .map { $0.capitalized } + .joined(separator: " ") + } +} + +// MARK: - Failed Download Row + +private struct FailedDownloadRow: View { + let progress: DownloadProgress + let key: String + @StateObject private var downloadService = AudioDownloadService.shared + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + + VStack(alignment: .leading, spacing: 4) { + Text(formatSlug(progress.slug)) + .font(.subheadline.bold()) + .lineLimit(1) + Text("Chapter \(progress.chapter)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + // Retry button + Button { + Task { + // Remove failed status + downloadService.downloads.removeValue(forKey: key) + // Retry download + try? await downloadService.download( + slug: progress.slug, + chapter: progress.chapter, + voice: progress.voice + ) + } + } label: { + Text("Retry") + .font(.caption.bold()) + .foregroundStyle(.amber) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.amber.opacity(0.15), in: Capsule()) + } + .buttonStyle(.plain) + } + .padding(.vertical, 4) + } + + private func formatSlug(_ slug: String) -> String { + slug.split(separator: "-") + .map { $0.capitalized } + .joined(separator: " ") + } +} diff --git a/ios/LibNovel/LibNovel/Views/Downloads/DownloadsView.swift b/ios/LibNovel/LibNovel/Views/Downloads/DownloadsView.swift new file mode 100644 index 0000000..219c447 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Downloads/DownloadsView.swift @@ -0,0 +1,216 @@ +import SwiftUI + +// MARK: - Downloads Management View +// Shows all downloaded audio chapters and allows deletion + +struct DownloadsView: View { + @StateObject private var downloadService = AudioDownloadService.shared + @Environment(\.dismiss) private var dismiss + + private var sortedDownloads: [(key: String, value: DownloadProgress)] { + downloadService.downloads.sorted { $0.key < $1.key } + } + + private var totalStorageFormatted: String { + let bytes = downloadService.getTotalStorageUsed() + return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } + + var body: some View { + NavigationStack { + Group { + if downloadService.downloadedChapters.isEmpty && downloadService.downloads.isEmpty { + // Empty state + VStack(spacing: 16) { + Image(systemName: "arrow.down.circle") + .font(.system(size: 56)) + .foregroundStyle(.secondary.opacity(0.5)) + Text("No Downloads") + .font(.title2.bold()) + .foregroundStyle(.primary) + Text("Downloaded audio chapters will appear here for offline listening") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + // Storage info section + Section { + HStack { + Image(systemName: "internaldrive") + .foregroundStyle(.amber) + Text("Total Storage Used") + Spacer() + Text(totalStorageFormatted) + .foregroundStyle(.secondary) + } + } + + // Active downloads + if !downloadService.downloads.isEmpty { + Section("Active Downloads") { + ForEach(sortedDownloads, id: \.key) { key, progress in + DownloadRow(progress: progress, key: key) + } + } + } + + // Downloaded chapters + if !downloadService.downloadedChapters.isEmpty { + Section("Downloaded (\(downloadService.downloadedChapters.count))") { + ForEach(Array(downloadService.downloadedChapters.sorted()), id: \.self) { key in + DownloadedChapterRow(key: key) + } + } + } + + // Delete all button + if !downloadService.downloadedChapters.isEmpty { + Section { + Button(role: .destructive) { + try? downloadService.deleteAllDownloads() + } label: { + HStack { + Spacer() + Text("Delete All Downloads") + .font(.subheadline.bold()) + Spacer() + } + } + } + } + } + } + } + .navigationTitle("Downloads") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .foregroundStyle(.amber) + } + } + } + } +} + +// MARK: - Download Row (in progress) + +private struct DownloadRow: View { + let progress: DownloadProgress + let key: String + @StateObject private var downloadService = AudioDownloadService.shared + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Chapter \(progress.chapter)") + .font(.subheadline.bold()) + Text(progress.slug) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if progress.status == .downloading { + VStack(alignment: .trailing, spacing: 4) { + Text("\(Int(progress.progress * 100))%") + .font(.caption) + .foregroundStyle(.secondary) + ProgressView(value: progress.progress) + .frame(width: 60) + } + + Button { + downloadService.cancelDownload(slug: progress.slug, chapter: progress.chapter, voice: progress.voice) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } else if case .failed(let error) = progress.status { + VStack(alignment: .trailing) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text("Failed") + .font(.caption2) + .foregroundStyle(.red) + } + } + } + } +} + +// MARK: - Downloaded Chapter Row + +private struct DownloadedChapterRow: View { + let key: String + @StateObject private var downloadService = AudioDownloadService.shared + + private var components: (slug: String, chapter: String, voice: String) { + let parts = key.split(separator: "-") + if parts.count >= 3 { + return (String(parts[0]), String(parts[1]), parts[2...].joined(separator: "-")) + } + return ("", "", "") + } + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Chapter \(components.chapter)") + .font(.subheadline.bold()) + HStack(spacing: 4) { + Text(components.slug) + .font(.caption) + .foregroundStyle(.secondary) + Text("•") + .font(.caption) + .foregroundStyle(.secondary) + Text(formatVoice(components.voice)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + let parts = components + if let chapter = Int(parts.chapter) { + try? downloadService.deleteDownload(slug: parts.slug, chapter: chapter, voice: parts.voice) + } + } label: { + Label("Delete", systemImage: "trash") + } + } + } + + private func formatVoice(_ voice: String) -> String { + // Format voice name (e.g., "af_bella" -> "Bella (US F)") + let parts = voice.split(separator: "_") + guard parts.count == 2 else { return voice } + + let prefix = String(parts[0]) + let name = String(parts[1]).capitalized + + let gender = prefix.hasSuffix("f") ? "F" : prefix.hasSuffix("m") ? "M" : "" + let accent = prefix.hasPrefix("af") ? "US" : prefix.hasPrefix("bf") || prefix.hasPrefix("bm") ? "UK" : "" + + if !gender.isEmpty && !accent.isEmpty { + return "\(name) (\(accent) \(gender))" + } else if !gender.isEmpty { + return "\(name) (\(gender))" + } else { + return name + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift new file mode 100644 index 0000000..35fdaff --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift @@ -0,0 +1,451 @@ +import SwiftUI + +struct HomeView: View { + @StateObject private var vm = HomeViewModel() + @EnvironmentObject var authStore: AuthStore + @StateObject private var downloadService = AudioDownloadService.shared + + private var offlineBooks: [Book] { + let offlineSlugs = downloadService.getOfflineBookSlugs() + // Filter continue reading items that have offline downloads + return vm.continueReading + .filter { offlineSlugs.contains($0.book.slug) } + .map { $0.book } + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + OfflineBanner() + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + + // Continue reading — all in-progress books as a horizontal shelf (Apple Books style) + if !vm.continueReading.isEmpty { + ShelfHeader(title: "Continue Reading") + .padding(.top, 8) + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 16) { + ForEach(vm.continueReading) { item in + NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) { + ContinueReadingCard(item: item) + } + .buttonStyle(.plain) + .contextMenu { + ContinueReadingContextMenu( + item: item, + onMarkFinished: { + Task { await markAsFinished(item.book) } + }, + onRemove: { + Task { await removeFromLibrary(item.book.slug) } + } + ) + } + } + } + .padding(.horizontal) + .padding(.bottom, 4) + } + .padding(.bottom, 28) + } + + // Offline books — books with downloaded chapters + if !offlineBooks.isEmpty { + HStack { + ShelfHeader(title: "Downloaded for Offline") + Spacer() + Image(systemName: "wifi.slash") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.trailing, 16) + } + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 14) { + ForEach(offlineBooks) { book in + NavigationLink(value: NavDestination.book(book.slug)) { + VStack(alignment: .leading, spacing: 8) { + ShelfBookCard(book: book) + HStack(spacing: 4) { + Image(systemName: "arrow.down.circle.fill") + .font(.caption2) + .foregroundStyle(.green) + Text("\(downloadService.getDownloadedChapterCount(for: book.slug)) chapters") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 4) + } + } + .buttonStyle(.plain) + .contextMenu { + ShareLink(item: shareURL(for: book)) { + Label("Share", systemImage: "square.and.arrow.up") + } + } + } + } + .padding(.horizontal) + .padding(.bottom, 4) + } + .padding(.bottom, 28) + } + + // Stats strip + if let stats = vm.stats { + StatsStrip(stats: stats) + .padding(.horizontal) + .padding(.bottom, 28) + } + + // Recently updated shelf + if !vm.recentlyUpdated.isEmpty { + ShelfHeader(title: "Recently Updated") + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 14) { + ForEach(vm.recentlyUpdated) { book in + NavigationLink(value: NavDestination.book(book.slug)) { + ShelfBookCard(book: book) + } + .buttonStyle(.plain) + .contextMenu { + ShareLink(item: shareURL(for: book)) { + Label("Share", systemImage: "square.and.arrow.up") + } + } + } + } + .padding(.horizontal) + .padding(.bottom, 4) + } + .padding(.bottom, 28) + } + + // Subscription feed shelf + if !vm.subscriptionFeed.isEmpty { + ShelfHeader(title: "From People You Follow") + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 14) { + ForEach(vm.subscriptionFeed) { item in + NavigationLink(value: NavDestination.book(item.book.slug)) { + SubscriptionFeedCard(item: item) + } + .buttonStyle(.plain) + .contextMenu { + ShareLink(item: shareURL(for: item.book)) { + Label("Share", systemImage: "square.and.arrow.up") + } + } + } + } + .padding(.horizontal) + .padding(.bottom, 4) + } + .padding(.bottom, 28) + } + + // Empty state + if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && vm.subscriptionFeed.isEmpty && !vm.isLoading { + EmptyStateView( + icon: "books.vertical", + title: "Your library is empty", + message: "Head to Discover to find novels to read." + ) + .frame(maxWidth: .infinity) + .padding(.top, 60) + } + + if vm.isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 60) + } + + Color.clear.frame(height: 20) + } + } + .navigationTitle("Reading Now") + .appNavigationDestination() + .refreshable { await vm.load() } + .task { await vm.load() } + .errorAlert($vm.error) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 8) { + DownloadQueueButton() + Divider() + .frame(height: 18) + AvatarToolbarButton() + } + } + } + } + } + } + + private func markAsFinished(_ book: Book) async { + do { + try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters) + await vm.load() // Refresh home + } catch { + vm.error = error.localizedDescription + } + } + + private func removeFromLibrary(_ slug: String) async { + do { + try await APIClient.shared.deleteProgress(slug: slug) + await vm.load() // Refresh home + } catch { + vm.error = error.localizedDescription + } + } + + private func shareURL(for book: Book) -> URL { + let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String + ?? "https://v2.libnovel.kalekber.cc" + return URL(string: "\(baseURL)/books/\(book.slug)")! + } +} + +// MARK: - Horizontal shelf: continue reading card (Apple Books style) + +private struct ContinueReadingCard: View { + let item: ContinueReadingItem + + private var progressFraction: Double { + guard item.book.totalChapters > 0 else { return 0 } + return min(1.0, Double(item.chapter) / Double(item.book.totalChapters)) + } + + private var progressText: String { + let percentage = progressFraction * 100 + + // For books with many chapters, show decimal precision when less than 10% + if percentage < 10 && percentage > 0 { + return String(format: "%.1f%% complete", percentage) + } + + // Otherwise, round to nearest integer (min 1% if any progress exists) + let rounded = max(1, Int(round(percentage))) + return "\(rounded)% complete" + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Cover + ZStack(alignment: .bottom) { + AsyncCoverImage(url: item.book.cover) + .frame(width: 130, height: 188) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .shadow(color: .black.opacity(0.22), radius: 8, y: 4) + .bookCoverZoomSource(slug: item.book.slug) + + // Gradient scrim so badge is always readable + LinearGradient( + colors: [Color.black.opacity(0), Color.black.opacity(0.55)], + startPoint: .center, + endPoint: .bottom + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .frame(height: 60) + + // "Continue" pill badge — centered at bottom over the scrim + HStack(spacing: 4) { + Image(systemName: "play.fill") + .font(.system(size: 8, weight: .bold)) + Text("Ch.\(item.chapter)") + .font(.system(size: 10, weight: .bold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Capsule().fill(Color.amber)) + .padding(.bottom, 10) + } + + // Title + Text(item.book.title) + .font(.caption.bold()) + .lineLimit(2) + .frame(width: 130, alignment: .leading) + .foregroundStyle(.primary) + + // Progress bar — show at least a 4pt sliver so early chapters aren't invisible + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.secondary.opacity(0.2)) + Capsule() + .fill(Color.amber.opacity(0.9)) + .frame(width: max(4, geo.size.width * progressFraction)) + } + } + .frame(width: 130, height: 3) + + // Progress label with smart rounding + Text(progressText) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(width: 130) + } +} + +// MARK: - Horizontal shelf: recently updated book card + +private struct ShelfBookCard: View { + let book: Book + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ZStack(alignment: .topTrailing) { + AsyncCoverImage(url: book.cover) + .frame(width: 110, height: 158) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .shadow(color: .black.opacity(0.12), radius: 4, y: 2) + .bookCoverZoomSource(slug: book.slug) + + // Chapter count badge + Text("\(book.totalChapters) ch") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Capsule().fill(Color.black.opacity(0.55))) + .padding(6) + } + + Text(book.title) + .font(.caption.bold()) + .lineLimit(2) + .frame(width: 110, alignment: .leading) + + Text(book.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: 110, alignment: .leading) + } + } +} + +// MARK: - Horizontal shelf: subscription feed card + +private struct SubscriptionFeedCard: View { + let item: SubscriptionFeedItem + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + AsyncCoverImage(url: item.book.cover) + .frame(width: 110, height: 158) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .shadow(color: .black.opacity(0.12), radius: 4, y: 2) + .bookCoverZoomSource(slug: item.book.slug) + + Text(item.book.title) + .font(.caption.bold()) + .lineLimit(2) + .frame(width: 110, alignment: .leading) + + // Tappable "via @username" attribution + NavigationLink(value: NavDestination.userProfile(item.readerUsername)) { + Text("via @\(item.readerUsername)") + .font(.caption2) + .foregroundStyle(Color.amber) + .lineLimit(1) + .frame(width: 110, alignment: .leading) + } + .buttonStyle(.plain) + } + } +} + +// MARK: - Stats strip (compact inline) + +private struct StatsStrip: View { + let stats: HomeStats + + var body: some View { + HStack(spacing: 0) { + StatPill(icon: "books.vertical.fill", value: "\(stats.totalBooks)", label: "Books") + Divider().frame(height: 28) + StatPill(icon: "text.alignleft", value: "\(stats.totalChapters)", label: "Chapters") + Divider().frame(height: 28) + StatPill(icon: "bookmark.fill", value: "\(stats.booksInProgress)", label: "In Progress") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14)) + } +} + +private struct StatPill: View { + let icon: String + let value: String + let label: String + + var body: some View { + VStack(spacing: 5) { + Image(systemName: icon) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color.amber) + Text(value) + .font(.subheadline.bold().monospacedDigit()) + .foregroundStyle(.primary) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } +} + +// MARK: - Context menus + +private struct ContinueReadingContextMenu: View { + let item: ContinueReadingItem + let onMarkFinished: () -> Void + let onRemove: () -> Void + + private var isFinished: Bool { + guard item.book.totalChapters > 0 else { return false } + return item.chapter >= item.book.totalChapters + } + + var body: some View { + Group { + // Share book + ShareLink(item: shareURL) { + Label("Share", systemImage: "square.and.arrow.up") + } + + Divider() + + // Mark as finished (only show if not already finished) + if !isFinished { + Button { + onMarkFinished() + } label: { + Label("Mark as Finished", systemImage: "checkmark.circle") + } + } + + Divider() + + // Remove from library (destructive) + Button(role: .destructive) { + onRemove() + } label: { + Label("Remove from Library", systemImage: "trash") + } + } + } + + private var shareURL: URL { + let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String + ?? "https://v2.libnovel.kalekber.cc" + return URL(string: "\(baseURL)/books/\(item.book.slug)")! + } +} diff --git a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift new file mode 100644 index 0000000..a5921d2 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift @@ -0,0 +1,391 @@ +import SwiftUI +import Kingfisher + +struct LibraryView: View { + @StateObject private var vm = LibraryViewModel() + @State private var sortOrder: SortOrder = .recentlyRead + @State private var readingFilter: ReadingFilter = .all + @State private var selectedGenre: String = "all" + + enum SortOrder: String, CaseIterable { + case recentlyRead = "Recent" + case title = "Title" + case author = "Author" + case progress = "Progress" + } + + enum ReadingFilter: String, CaseIterable { + case all = "All" + case inProgress = "In Progress" + case completed = "Completed" + } + + // All distinct genres across the library, sorted alphabetically. + private var availableGenres: [String] { + let all = vm.items.flatMap { $0.book.genres } + let unique = Array(Set(all)).sorted() + return unique + } + + private var filtered: [LibraryItem] { + var result = vm.items + + // 1. Reading filter + switch readingFilter { + case .all: + break + case .inProgress: + result = result.filter { !isCompleted($0) } + case .completed: + result = result.filter { isCompleted($0) } + } + + // 2. Genre filter + if selectedGenre != "all" { + result = result.filter { $0.book.genres.contains(selectedGenre) } + } + + // 3. Sort + switch sortOrder { + case .recentlyRead: + break // server returns by recency + case .title: + result = result.sorted { $0.book.title < $1.book.title } + case .author: + result = result.sorted { $0.book.author < $1.book.author } + case .progress: + result = result.sorted { ($0.lastChapter ?? 0) > ($1.lastChapter ?? 0) } + } + + return result + } + + private func isCompleted(_ item: LibraryItem) -> Bool { + // Treat as completed if book status is "completed" OR + // the user has read up to (or past) the total chapter count. + if item.book.status.lowercased() == "completed", + let ch = item.lastChapter, + item.book.totalChapters > 0, + ch >= item.book.totalChapters { + return true + } + return item.book.status.lowercased() == "completed" && (item.lastChapter ?? 0) > 0 + } + + private func markAsFinished(_ book: Book) async { + do { + try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters) + await vm.load() // Refresh library + } catch { + vm.error = error.localizedDescription + } + } + + private func removeFromLibrary(_ slug: String) async { + do { + try await APIClient.shared.deleteProgress(slug: slug) + await vm.load() // Refresh library + } catch { + vm.error = error.localizedDescription + } + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + OfflineBanner() + + Group { + if vm.isLoading && vm.items.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if vm.items.isEmpty { + EmptyStateView( + icon: "bookmark", + title: "No saved books", + message: "Books you save or start reading will appear here." + ) + } else { + ScrollView { + VStack(spacing: 0) { + // Reading filter (All / In Progress / Completed) + Picker("", selection: $readingFilter) { + ForEach(ReadingFilter.allCases, id: \.self) { f in + Text(f.rawValue).tag(f) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.top, 16) + + // Genre filter chips (only shown when genres are available) + if !availableGenres.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + // "All" chip + ChipButton( + label: "All", + isSelected: selectedGenre == "all", + style: .filled + ) { + withAnimation { selectedGenre = "all" } + } + ForEach(availableGenres, id: \.self) { genre in + ChipButton( + label: genre.capitalized, + isSelected: selectedGenre == genre, + style: .filled + ) { + withAnimation { + selectedGenre = selectedGenre == genre ? "all" : genre + } + } + } + } + .padding(.horizontal) + } + .padding(.top, 10) + } + + // Sort chips + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(SortOrder.allCases, id: \.self) { order in + ChipButton( + label: order.rawValue, + isSelected: sortOrder == order, + style: .outlined + ) { + withAnimation { sortOrder = order } + } + } + } + .padding(.horizontal) + } + .padding(.vertical, 10) + + // Book count + Text("\(filtered.count) book\(filtered.count == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.bottom, 4) + + if filtered.isEmpty { + VStack(spacing: 12) { + Image(systemName: readingFilter == .completed ? "checkmark.circle" : "book") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + Text(emptyMessage) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.top, 60) + } else { + // 2-column grid (matches Discover) + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 14), + GridItem(.flexible(), spacing: 14) + ], + spacing: 14 + ) { + ForEach(filtered) { item in + NavigationLink(value: NavDestination.book(item.book.slug)) { + LibraryBookCard(item: item) + } + .buttonStyle(.plain) + .contextMenu { + BookContextMenu( + book: item.book, + isFinished: isCompleted(item), + onMarkFinished: { + Task { + await markAsFinished(item.book) + } + }, + onRemove: { + Task { + await removeFromLibrary(item.book.slug) + } + } + ) + } + } + } + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 100) + } + } + } + } + } + .navigationTitle("Library") + .appNavigationDestination() + .refreshable { await vm.load() } + .task { await vm.load() } + .errorAlert($vm.error) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 16) { + DownloadQueueButton() + AvatarToolbarButton() + } + } + } + } + } + + var emptyMessage: String { + switch readingFilter { + case .all: + return selectedGenre == "all" ? "No books in your library." : "No \(selectedGenre.capitalized) books in your library." + case .inProgress: + return "No books in progress." + case .completed: + return "No completed books yet." + } + } + } + + // MARK: - Library book card (3-column) + + private struct LibraryBookCard: View { + let item: LibraryItem + + private var progressFraction: Double { + guard let ch = item.lastChapter, item.book.totalChapters > 0 else { return 0 } + return Double(ch) / Double(item.book.totalChapters) + } + + private var isCompleted: Bool { + progressFraction >= 1.0 + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .topTrailing) { + // Cover image + KFImage(URL(string: item.book.cover)) + .resizable() + .placeholder { + RoundedRectangle(cornerRadius: 10) + .fill(Color(.systemGray5)) + .overlay( + Image(systemName: "book.closed") + .foregroundStyle(.secondary) + ) + } + .scaledToFill() + .frame(maxWidth: .infinity) + .aspectRatio(2/3, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .bookCoverZoomSource(slug: item.book.slug) + + // Progress arc or completed checkmark in top-right corner + if isCompleted { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + .background(Circle().fill(Color.amber).padding(1)) + .padding(6) + } else if progressFraction > 0 { + ProgressArc(fraction: progressFraction) + .frame(width: 28, height: 28) + .padding(5) + } + } + + // Title + chapter badge + VStack(alignment: .leading, spacing: 3) { + Text(item.book.title) + .font(.subheadline.bold()) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + + if let ch = item.lastChapter { + Text(isCompleted ? "Finished" : "Ch.\(ch)") + .font(.caption) + .foregroundStyle(isCompleted ? Color.amber : .secondary) + .lineLimit(1) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) + } + } + + // MARK: - Circular progress arc overlay + + private struct ProgressArc: View { + let fraction: Double // 0...1 + + var body: some View { + ZStack { + Circle() + .fill(.ultraThinMaterial) + + Circle() + .trim(from: 0, to: fraction) + .stroke(Color.amber, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.5), value: fraction) + } + } + } + + // MARK: - Book context menu + + private struct BookContextMenu: View { + let book: Book + let isFinished: Bool + let onMarkFinished: () -> Void + let onRemove: () -> Void + + var body: some View { + Group { + // Share book + ShareLink(item: shareURL) { + Label("Share", systemImage: "square.and.arrow.up") + } + + Divider() + + // Mark as finished (only show if not already finished) + if !isFinished { + Button { + onMarkFinished() + } label: { + Label("Mark as Finished", systemImage: "checkmark.circle") + } + } + + Divider() + + // Remove from library (destructive) + Button(role: .destructive) { + onRemove() + } label: { + Label("Remove from Library", systemImage: "trash") + } + } + } + + private var shareURL: URL { + // Share the book detail page URL + let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String + ?? "https://v2.libnovel.kalekber.cc" + return URL(string: "\(baseURL)/books/\(book.slug)")! + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift new file mode 100644 index 0000000..06ba600 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift @@ -0,0 +1,2060 @@ +import SwiftUI +import Kingfisher // used directly for blurred background in FullPlayerView +import AVKit // for AVRoutePickerView (AirPlay) + +// MARK: - Mini player bar (Spotify-style, fixed above tab bar) +// Replaces the old FloatingPlayerButton + CompactPlayerControls. +// Swipe up → full player. Swipe down → stop. Tap cover/track info → full player. +// All transport buttons are plain Buttons with no competing gesture recognisers. + +struct MiniPlayerBar: View { + @Binding var showFullPlayer: Bool + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var downloadService: AudioDownloadService + + /// Live vertical drag offset while the user swipes up/down. + @State private var dragOffset: CGFloat = 0 + + private var isCurrentChapterDownloaded: Bool { + downloadService.isDownloaded( + slug: audioPlayer.slug, + chapter: audioPlayer.chapter, + voice: audioPlayer.voice + ) + } + + var body: some View { + VStack(spacing: 0) { + // ── Thin amber progress strip at the very top ──────────────── + MiniBarProgress(progress: audioPlayer.progress) + + // ── Main content row ───────────────────────────────────────── + HStack(spacing: 12) { + // Cover art — tap opens full player + Button { showFullPlayer = true } label: { + AsyncCoverImage(url: audioPlayer.coverURL) + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 9)) + .shadow(color: .black.opacity(0.18), radius: 6, y: 2) + } + .buttonStyle(.plain) + + // Track info — tap opens full player + Button { showFullPlayer = true } label: { + VStack(alignment: .leading, spacing: 2) { + Text(chapterLabel) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + HStack(spacing: 4) { + Text(audioPlayer.bookTitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + if isCurrentChapterDownloaded { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 9)) + .foregroundStyle(.green) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + + // ── Transport controls ─────────────────────────────────── + + // Previous chapter + Button { + if let prev = audioPlayer.prevChapter { + NotificationCenter.default.post( + name: .skipToPrevChapter, + object: nil, + userInfo: ["prev": prev] + ) + } + } label: { + Image(systemName: "backward.end.fill") + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(audioPlayer.prevChapter == nil) + .opacity(audioPlayer.prevChapter == nil ? 0.3 : 1) + + // Play / pause (isolated observer — only re-renders this button) + MiniBarPlayPause(progress: audioPlayer.progress) { + audioPlayer.togglePlayPause() + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + .disabled(audioPlayer.status == .generating) + + // Next chapter + Button { + if let next = audioPlayer.nextChapter { + NotificationCenter.default.post( + name: .skipToNextChapter, + object: nil, + userInfo: ["next": next] + ) + } + } label: { + Image(systemName: "forward.end.fill") + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(audioPlayer.nextChapter == nil) + .opacity(audioPlayer.nextChapter == nil ? 0.3 : 1) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .background(.regularMaterial) + .offset(y: dragOffset) + .opacity(dragOffset > 0 ? max(0.3, 1 - dragOffset / 200) : 1) + .gesture( + DragGesture(minimumDistance: 8, coordinateSpace: .local) + .onChanged { value in + let dy = value.translation.height + dragOffset = dy < 0 ? dy * 0.25 : dy * 0.7 + } + .onEnded { value in + let dy = value.translation.height + let velocity = value.predictedEndTranslation.height - value.translation.height + if dy < -30 || velocity < -150 { + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { dragOffset = 0 } + showFullPlayer = true + } else if dy > 60 || velocity > 200 { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { dragOffset = 200 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { audioPlayer.stop() } + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { dragOffset = 0 } + } + } + ) + } + + private var chapterLabel: String { + let raw = audioPlayer.chapterTitle.isEmpty + ? "Chapter \(audioPlayer.chapter)" + : audioPlayer.chapterTitle + return raw.strippingTrailingDate() + } +} + +// MARK: - Isolated progress strip (observes PlaybackProgress directly) + +private struct MiniBarProgress: View { + @ObservedObject var progress: PlaybackProgress + + var body: some View { + GeometryReader { geo in + let fraction = progress.duration > 0 + ? CGFloat(progress.currentTime / progress.duration) + : 0 + Rectangle() + .fill(Color.amber) + .frame(width: geo.size.width * max(0, min(1, fraction)), height: 2) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 2) + } +} + +// MARK: - Isolated play/pause for mini bar (observes PlaybackProgress directly) + +private struct MiniBarPlayPause: View { + @ObservedObject var progress: PlaybackProgress + let onToggle: () -> Void + + var body: some View { + Button(action: onToggle) { + Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 21, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) + .contentTransition(.symbolEffect(.replace.downUp)) + } + .buttonStyle(.plain) + } +} + +// CompactPlayerControls removed — replaced by MiniPlayerBar above. + +// MARK: - Full player sheet + +struct FullPlayerView: View { + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var downloadService: AudioDownloadService + @EnvironmentObject var authStore: AuthStore + /// Called when the view wants to close itself (Done button or drag-to-dismiss). + var onDismiss: () -> Void = {} + + @State private var showingChaptersList = false + @State private var showingSleepTimer = false + @State private var showingVoiceSelector = false + @StateObject private var voiceVM = VoiceSelectionViewModel() + @State private var coverAppeared = false + + private var isCurrentChapterDownloaded: Bool { + downloadService.isDownloaded( + slug: audioPlayer.slug, + chapter: audioPlayer.chapter, + voice: audioPlayer.voice + ) + } + + private var currentDownloadProgress: DownloadProgress? { + let key = downloadService.makeKey( + slug: audioPlayer.slug, + chapter: audioPlayer.chapter, + voice: audioPlayer.voice + ) + return downloadService.downloads[key] + } + + var body: some View { + GeometryReader { geo in + ZStack { + // ── Background: blurred cover art ────────────────────────── + KFImage(URL(string: audioPlayer.coverURL)) + .resizable() + .scaledToFill() + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + .blur(radius: 55, opaque: true) + .overlay(Color.black.opacity(0.55)) + .ignoresSafeArea() + .id(audioPlayer.coverURL) // re-render background on track change + + // ── Content ──────────────────────────────────────────────── + VStack(spacing: 0) { + // Drag handle + Capsule() + .fill(Color.white.opacity(0.25)) + .frame(width: 36, height: 4) + .padding(.top, 14) + + // ── Cover art ────────────────────────────────────────── + let coverSize = min(geo.size.width - 56, geo.size.height * 0.42) + ZStack { + KFImage(URL(string: audioPlayer.coverURL)) + .resizable() + .placeholder { + RoundedRectangle(cornerRadius: 22) + .fill(.white.opacity(0.08)) + .overlay( + Image(systemName: "book.closed") + .font(.system(size: 56)) + .foregroundStyle(.white.opacity(0.25)) + ) + } + .scaledToFill() + .frame(width: coverSize, height: coverSize) + .clipShape(RoundedRectangle(cornerRadius: 22)) + .shadow(color: .black.opacity(0.55), radius: 36, y: 18) + .overlay( + RoundedRectangle(cornerRadius: 22) + .fill(Color.black.opacity(audioPlayer.status == .generating ? 0.5 : 0)) + .animation(.easeInOut(duration: 0.3), value: audioPlayer.status == .generating) + ) + // Subtle scale pulse while playing + .scaleEffect(audioPlayer.progress.isPlaying && !coverAppeared ? 1.0 : (audioPlayer.progress.isPlaying ? 1.02 : 0.97)) + .animation(.spring(response: 0.45, dampingFraction: 0.7), value: audioPlayer.progress.isPlaying) + + // Generating overlay + if audioPlayer.status == .generating { + VStack(spacing: 10) { + ProgressView() + .tint(.white) + .scaleEffect(1.4) + Text("Generating audio…") + .font(.caption.weight(.medium)) + .foregroundStyle(.white.opacity(0.8)) + } + .transition(.opacity) + } + + // Voice watermark — bottom-left corner + VStack { + Spacer() + HStack { + Text(voiceName) + .font(.custom("Snell Roundhand", size: 17)) + .foregroundStyle(.white.opacity(0.5)) + .shadow(color: .black.opacity(0.5), radius: 2) + .padding(12) + Spacer() + } + } + .frame(width: coverSize, height: coverSize) + } + .frame(width: coverSize, height: coverSize) + .padding(.top, 18) + .onAppear { + withAnimation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1)) { + coverAppeared = true + } + } + .onChange(of: audioPlayer.slug) { _, _ in + coverAppeared = false + withAnimation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.05)) { + coverAppeared = true + } + } + + // ── Title block ──────────────────────────────────────── + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text((audioPlayer.chapterTitle.isEmpty + ? "Chapter \(audioPlayer.chapter)" + : audioPlayer.chapterTitle).strippingTrailingDate()) + .font(.title3.weight(.bold)) + .foregroundStyle(.white) + .lineLimit(2) + Text(audioPlayer.bookTitle) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + + HStack(spacing: 8) { + if !audioPlayer.chapters.isEmpty { + Text(chapterPositionText) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.white.opacity(0.3)) + } + + // Download badge + if let progress = currentDownloadProgress { + Label("\(Int(progress.progress * 100))%", systemImage: "arrow.down.circle") + .font(.caption2) + .foregroundStyle(.blue) + } else if isCurrentChapterDownloaded { + Label("Offline", systemImage: "checkmark.circle.fill") + .font(.caption2) + .foregroundStyle(.green) + } + } + .padding(.top, 1) + } + .frame(maxWidth: .infinity, alignment: .leading) + + // Quick download + if !isCurrentChapterDownloaded && currentDownloadProgress == nil { + Button { + Task { + try? await downloadService.download( + slug: audioPlayer.slug, + chapter: audioPlayer.chapter, + voice: audioPlayer.voice + ) + } + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } label: { + Image(systemName: "arrow.down.circle") + .font(.system(size: 24)) + .foregroundStyle(.white.opacity(0.65)) + } + .buttonStyle(.plain) + } + + // Auto-next toggle + Button { + audioPlayer.autoNext.toggle() + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } label: { + Image(systemName: audioPlayer.autoNext ? "infinity.circle.fill" : "infinity.circle") + .font(.system(size: 28)) + .foregroundStyle(audioPlayer.autoNext ? Color.amber : .white.opacity(0.4)) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 28) + .padding(.top, 22) + + // ── Seek bar ─────────────────────────────────────────── + PlayerProgressSection( + progress: audioPlayer.progress, + onSeek: { audioPlayer.seek(to: $0) } + ) + .padding(.top, 18) + .opacity(audioPlayer.status == .generating ? 0.3 : 1) + .allowsHitTesting(audioPlayer.status != .generating) + + // ── Transport controls ───────────────────────────────── + HStack(spacing: 0) { + PlayerSecondaryButton( + systemName: "gobackward.15", + size: 24, + disabled: audioPlayer.status == .generating + ) { + audioPlayer.skip(by: -15) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + + PlayerChapterSkipButton( + systemName: "backward.end.fill", + size: 30, + disabled: audioPlayer.prevChapter == nil + ) { + if let prev = audioPlayer.prevChapter { + NotificationCenter.default.post( + name: .skipToPrevChapter, object: nil, + userInfo: ["prev": prev] + ) + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + } + + PlayerPlayPauseButton( + progress: audioPlayer.progress, + isGenerating: audioPlayer.status == .generating, + onToggle: { + audioPlayer.togglePlayPause() + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + ) + + PlayerChapterSkipButton( + systemName: "forward.end.fill", + size: 30, + disabled: audioPlayer.nextChapter == nil, + prefetching: audioPlayer.nextPrefetchStatus == .prefetching + ) { + if let next = audioPlayer.nextChapter { + NotificationCenter.default.post( + name: .skipToNextChapter, object: nil, + userInfo: ["next": next] + ) + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + } + + PlayerSecondaryButton( + systemName: "goforward.15", + size: 24, + disabled: audioPlayer.status == .generating + ) { + audioPlayer.skip(by: 15) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 8) + + // ── Bottom toolbar ───────────────────────────────────── + HStack(spacing: 0) { + // AirPlay + AirPlayButton() + .frame(width: 24, height: 24) + .frame(maxWidth: .infinity) + + // Speed + Menu { + ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in + Button { + audioPlayer.setSpeed(s) + } label: { + if s == audioPlayer.speed { + Label("\(s, specifier: "%.2g")×", systemImage: "checkmark") + } else { + Text("\(s, specifier: "%.2g")×") + } + } + } + } label: { + Text("\(audioPlayer.speed, specifier: "%.2g")×") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white.opacity(0.65)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule().fill(.white.opacity(0.12)) + ) + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .buttonStyle(.plain) + + // Voice Selector + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + showingVoiceSelector.toggle() + } + if !showingVoiceSelector { + voiceVM.stopSample() + } + } label: { + Image(systemName: showingVoiceSelector ? "mic.fill" : "mic") + .font(.system(size: 20)) + .foregroundStyle(showingVoiceSelector ? Color.amber : .white.opacity(0.65)) + .frame(maxWidth: .infinity) + .frame(height: 44) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + + // Collapse + Button { onDismiss() } label: { + Image(systemName: "chevron.down") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white.opacity(0.65)) + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .buttonStyle(.plain) + + // Chapters list + Button { showingChaptersList = true } label: { + Image(systemName: "list.bullet") + .font(.system(size: 20)) + .foregroundStyle(.white.opacity(0.65)) + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .buttonStyle(.plain) + + // Sleep timer + Button { showingSleepTimer = true } label: { + VStack(spacing: 1) { + Image(systemName: sleepTimerIcon) + .font(.system(size: 20)) + .foregroundStyle(audioPlayer.sleepTimer != nil ? Color.amber : .white.opacity(0.65)) + .contentTransition(.symbolEffect(.replace)) + if !audioPlayer.sleepTimerRemainingText.isEmpty { + Text(audioPlayer.sleepTimerRemainingText) + .font(.system(size: 9, weight: .semibold).monospacedDigit()) + .foregroundStyle(Color.amber) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.bottom, showingVoiceSelector ? 0 : 8) + + // ── Voice selection panel (expandable) ──────────────── + if showingVoiceSelector { + VoiceSelectorPanel( + voices: voiceVM.voices, + selectedVoice: audioPlayer.voice, + playingVoice: voiceVM.playingVoice, + voiceVM: voiceVM, + onSelectVoice: { voice in + voiceVM.stopSample() + audioPlayer.voice = voice + BookVoicePreferences.shared.setVoice(voice, for: audioPlayer.slug) + Task { + var settings = authStore.settings + settings.voice = voice + await authStore.saveSettings(settings) + } + } + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .task { + if voiceVM.voices.isEmpty { + await voiceVM.loadVoices() + } + } + } + } + .ignoresSafeArea(edges: .bottom) + } + } + .ignoresSafeArea() + .sheet(isPresented: $showingChaptersList) { + ChaptersListSheet( + chapters: audioPlayer.chapters, + currentChapter: audioPlayer.chapter, + onChapterSelect: { selectedChapter in + showingChaptersList = false + guard selectedChapter != audioPlayer.chapter else { return } + + let chapterTitle = audioPlayer.chapters + .first(where: { $0.number == selectedChapter })?.title ?? "" + let nextChapter = audioPlayer.chapters + .filter({ $0.number > selectedChapter }) + .min(by: { $0.number < $1.number })?.number + let prevChapter: Int? = selectedChapter > 1 ? selectedChapter - 1 : nil + + audioPlayer.load( + slug: audioPlayer.slug, + chapter: selectedChapter, + chapterTitle: chapterTitle, + bookTitle: audioPlayer.bookTitle, + coverURL: audioPlayer.coverURL, + voice: audioPlayer.voice, + speed: audioPlayer.speed, + chapters: audioPlayer.chapters, + nextChapter: nextChapter, + prevChapter: prevChapter + ) + } + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $showingSleepTimer) { + SleepTimerSheet(audioPlayer: audioPlayer) + .presentationDetents([.height(500)]) + .presentationDragIndicator(.visible) + } + } + + // MARK: - Helpers + + private var chapterPositionText: String { + let total = audioPlayer.chapters.count + guard total > 0 else { return "" } + let sorted = audioPlayer.chapters.sorted(by: { $0.number < $1.number }) + let idx = (sorted.firstIndex(where: { $0.number == audioPlayer.chapter }) ?? 0) + 1 + return "Chapter \(idx) of \(total)" + } + + private var voiceName: String { + let components = audioPlayer.voice.split(separator: "_") + if components.count > 1 { return String(components[1]).capitalized } + return audioPlayer.voice.capitalized + } + + private var sleepTimerIcon: String { + audioPlayer.sleepTimer != nil ? "moon.zzz.fill" : "moon.zzz" + } +} + +// MARK: - Small secondary transport button (±15 s skips) + +private struct PlayerSecondaryButton: View { + let systemName: String + let size: CGFloat + let disabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: size, weight: .regular)) + .foregroundStyle(.white.opacity(disabled ? 0.3 : 0.85)) + .frame(maxWidth: .infinity) + .frame(height: 64) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + } +} + +// MARK: - Medium chapter-skip button (prev / next chapter) + +private struct PlayerChapterSkipButton: View { + let systemName: String + let size: CGFloat + let disabled: Bool + var prefetching: Bool = false + let action: () -> Void + + var body: some View { + Button(action: action) { + ZStack { + Image(systemName: systemName) + .font(.system(size: size, weight: .regular)) + .foregroundStyle(.white.opacity(disabled ? 0.3 : 0.9)) + + if prefetching { + VStack { + Spacer() + HStack { + Spacer() + ProgressView() + .scaleEffect(0.55) + .tint(.amber) + .padding(3) + .background(Circle().fill(.black.opacity(0.6))) + } + } + } + } + .frame(maxWidth: .infinity) + .frame(height: 64) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + .opacity(disabled ? 0.4 : 1.0) + } +} + +// MARK: - AirPlay Button + +struct AirPlayButton: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + let vc = UIViewController() + vc.view.backgroundColor = .clear + + let picker = AVRoutePickerView() + picker.tintColor = UIColor.white.withAlphaComponent(0.7) + picker.activeTintColor = UIColor(named: "AccentColor") ?? UIColor.systemOrange + picker.prioritizesVideoDevices = false + picker.translatesAutoresizingMaskIntoConstraints = false + + vc.view.addSubview(picker) + NSLayoutConstraint.activate([ + picker.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor), + picker.trailingAnchor.constraint(equalTo: vc.view.trailingAnchor), + picker.topAnchor.constraint(equalTo: vc.view.topAnchor), + picker.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor), + ]) + return vc + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +// MARK: - Sleep Timer Sheet + +struct SleepTimerSheet: View { + @ObservedObject var audioPlayer: AudioPlayerService + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 20) { + // ── Off card ────────────────────────────────────────── + TimerCard { + TimerOptionRow( + label: "Off", + systemImage: "moon.zzz", + isSelected: audioPlayer.sleepTimer == nil + ) { + audioPlayer.setSleepTimer(nil) + dismiss() + } + } + + // ── Chapter-based ───────────────────────────────────── + VStack(spacing: 0) { + SectionLabel("Chapter-based") + TimerCard { + ForEach([1, 2, 3, 4], id: \.self) { count in + let isSelected: Bool = { + if case .chapters(let c) = audioPlayer.sleepTimer { return c == count } + return false + }() + TimerOptionRow( + label: "\(count) \(count == 1 ? "chapter" : "chapters")", + systemImage: "book", + isSelected: isSelected + ) { + audioPlayer.setSleepTimer(.chapters(count)) + dismiss() + } + if count < 4 { Divider().padding(.leading, 56) } + } + } + } + + // ── Time-based ──────────────────────────────────────── + VStack(spacing: 0) { + SectionLabel("Time-based") + TimerCard { + ForEach([20, 40, 60, 120], id: \.self) { minutes in + let isSelected: Bool = { + if case .minutes(let m) = audioPlayer.sleepTimer { return m == minutes } + return false + }() + TimerOptionRow( + label: formatTimerOption(minutes), + systemImage: "clock", + isSelected: isSelected + ) { + audioPlayer.setSleepTimer(.minutes(minutes)) + dismiss() + } + if minutes != 120 { Divider().padding(.leading, 56) } + } + } + } + } + .padding(20) + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("Sleep Timer") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + } + } + } + } + + private func formatTimerOption(_ minutes: Int) -> String { + if minutes < 60 { return "\(minutes) mins" } + let hours = minutes / 60 + return "\(hours) \(hours == 1 ? "hour" : "hours")" + } +} + +// MARK: - Sleep timer helper views + +private struct TimerCard: View { + @ViewBuilder let content: Content + + var body: some View { + VStack(spacing: 0) { + content + } + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } +} + +private struct SectionLabel: View { + let text: String + init(_ text: String) { self.text = text } + + var body: some View { + Text(text.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 4) + .padding(.bottom, 8) + } +} + +private struct TimerOptionRow: View { + let label: String + let systemImage: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + action() + }) { + HStack(spacing: 14) { + Image(systemName: systemImage) + .font(.system(size: 16)) + .foregroundStyle(isSelected ? Color.amber : .secondary) + .frame(width: 28) + + Text(label) + .font(.body) + .foregroundStyle(.primary) + + Spacer() + + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.amber) + .transition(.scale.combined(with: .opacity)) + } + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected) + } +} + +// MARK: - Chapters List Sheet +// Apple Books-style: chapters grouped into blocks of 100 with a sticky jump +// bar along the right edge. A search bar filters by number or title. + +struct ChaptersListSheet: View { + let chapters: [ChapterIndexBrief] + let currentChapter: Int + let onChapterSelect: (Int) -> Void + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var downloadService: AudioDownloadService + + @State private var searchText: String = "" + @State private var filterOfflineOnly = false + @State private var showingDownloadAll = false + /// The block label the jump bar is currently scrolling to (e.g. "1–100"). + @State private var activeBlock: String? = nil + + // MARK: Derived data + + /// Count of downloaded chapters for this book + private var downloadedCount: Int { + chapters.filter { ch in + downloadService.isDownloaded( + slug: audioPlayer.slug, + chapter: ch.number, + voice: audioPlayer.voice + ) + }.count + } + + /// Count of downloading chapters + private var downloadingCount: Int { + downloadService.downloads.filter { key, _ in + key.hasPrefix("\(audioPlayer.slug)::") + }.count + } + + /// Chapters matching the current search query (or all chapters if empty). + private var filtered: [ChapterIndexBrief] { + var result = chapters + + // Apply offline filter + if filterOfflineOnly { + result = result.filter { ch in + downloadService.isDownloaded( + slug: audioPlayer.slug, + chapter: ch.number, + voice: audioPlayer.voice + ) + } + } + + // Apply search filter + if !searchText.isEmpty { + let q = searchText.lowercased() + result = result.filter { + "\($0.number)".contains(q) || $0.title.lowercased().contains(q) + } + } + + return result + } + + /// Chapters grouped into blocks of 100: ["1–100": [...], "101–200": [...], …] + /// When the user is searching or filtering we use a single "Results" group so the jump + /// bar hides and the flat list is shown directly. + private var groups: [(label: String, chapters: [ChapterIndexBrief])] { + guard searchText.isEmpty && !filterOfflineOnly else { + return filtered.isEmpty ? [] : [("Results", filtered)] + } + guard !filtered.isEmpty else { return [] } + let blockSize = 100 + let minN = filtered.map(\.number).min() ?? 1 + let maxN = filtered.map(\.number).max() ?? 1 + // Round down to the nearest block boundary for the first block start. + let firstBlock = ((minN - 1) / blockSize) * blockSize + 1 + var result: [(label: String, chapters: [ChapterIndexBrief])] = [] + var blockStart = firstBlock + while blockStart <= maxN { + let blockEnd = blockStart + blockSize - 1 + let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd } + if !slice.isEmpty { + result.append(("\(blockStart)–\(blockEnd)", slice)) + } + blockStart += blockSize + } + return result + } + + /// Jump-bar labels (shown only when not searching/filtering). + private var jumpLabels: [String] { groups.map(\.label) } + + // MARK: Body + + var body: some View { + NavigationStack { + ZStack(alignment: .trailing) { + // ── Main chapter list ────────────────────────────────────── + List { + // Download summary section + if downloadedCount > 0 || downloadingCount > 0 { + Section { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Offline Downloads") + .font(.headline) + Text("\(downloadedCount) of \(chapters.count) chapters") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + Button { + showingDownloadAll = true + } label: { + Label("Manage", systemImage: "arrow.down.circle") + .font(.subheadline.weight(.semibold)) + } + .buttonStyle(.bordered) + .tint(.blue) + } + + if downloadingCount > 0 { + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.8) + Text("Downloading \(downloadingCount) \(downloadingCount == 1 ? "chapter" : "chapters")") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // Quick filter toggle + Toggle("Show offline only", isOn: $filterOfflineOnly) + .font(.subheadline) + .tint(.amber) + } + .padding(.vertical, 8) + } + } + + ForEach(groups, id: \.label) { group in + // Section header — shows block range (e.g. "1–100") + Section { + ForEach(group.chapters, id: \.number) { ch in + ChapterRow( + chapter: ch, + isCurrent: ch.number == currentChapter, + onSelect: { onChapterSelect(ch.number) } + ) + .id(group.label) // anchor for jump-bar scrollTo + } + } header: { + if searchText.isEmpty && !filterOfflineOnly { + Text(group.label) + .font(.caption.bold()) + .foregroundStyle(.secondary) + .id("header_\(group.label)") + } + } + } + } + .listStyle(.plain) + .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Chapter number or title") + .scrollPosition(id: $activeBlock, anchor: .top) + + // ── Right-edge jump bar (hidden while searching/filtering) ─────────── + if searchText.isEmpty && !filterOfflineOnly && jumpLabels.count > 1 { + JumpBar(labels: jumpLabels, currentChapter: currentChapter, groups: groups) { label in + withAnimation { activeBlock = label } + } + .padding(.trailing, 4) + } + } + .navigationTitle("Chapters (\(filtered.count))") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + } + } + .sheet(isPresented: $showingDownloadAll) { + DownloadManagementSheet( + chapters: chapters, + slug: audioPlayer.slug, + voice: Binding( + get: { audioPlayer.voice }, + set: { audioPlayer.voice = $0 } + ) + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + // Scroll to the currently playing chapter's block on first appear. + .onAppear { + if let block = groups.first(where: { g in + g.chapters.contains(where: { $0.number == currentChapter }) + }) { + activeBlock = block.label + } + } + } + } +} + +// MARK: - Individual chapter row with download status + +private struct ChapterRow: View { + let chapter: ChapterIndexBrief + let isCurrent: Bool + let onSelect: () -> Void + + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var downloadService: AudioDownloadService + + private var isDownloaded: Bool { + downloadService.isDownloaded( + slug: audioPlayer.slug, + chapter: chapter.number, + voice: audioPlayer.voice + ) + } + + private var downloadProgress: DownloadProgress? { + let key = downloadService.makeKey(slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) + return downloadService.downloads[key] + } + + private var isDownloading: Bool { + downloadProgress != nil + } + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 14) { + // Number badge with download indicator + ZStack { + Text("\(chapter.number)") + .font(.caption.bold()) + .foregroundStyle(isCurrent ? .white : .secondary) + .frame(width: 40, height: 40) + .background( + Circle().fill(isCurrent ? Color.amber : Color(.systemGray5)) + ) + + // Download progress ring + if isDownloading, let progress = downloadProgress { + Circle() + .trim(from: 0, to: progress.progress) + .stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .frame(width: 44, height: 44) + .animation(.easeInOut(duration: 0.3), value: progress.progress) + } + } + + // Title + status subtitle + VStack(alignment: .leading, spacing: 3) { + Text(chapter.title.strippingTrailingDate()) + .font(.subheadline.weight(isCurrent ? .semibold : .regular)) + .foregroundStyle(.primary) + .lineLimit(2) + + HStack(spacing: 8) { + if isCurrent { + Label("Now Playing", systemImage: "waveform") + .font(.caption2) + .foregroundStyle(.amber) + .symbolEffect(.variableColor.cumulative, isActive: isCurrent) + } + + if isDownloading, let progress = downloadProgress { + Label("\(Int(progress.progress * 100))%", systemImage: "arrow.down.circle") + .font(.caption2) + .foregroundStyle(.blue) + } else if isDownloaded { + Label("Downloaded", systemImage: "checkmark.circle.fill") + .font(.caption2) + .foregroundStyle(.green) + } + } + } + + Spacer() + + // Right side indicator + if isCurrent { + Image(systemName: "waveform") + .font(.caption.bold()) + .foregroundStyle(.amber) + .symbolEffect(.variableColor.cumulative, isActive: isCurrent) + } else if isDownloaded { + Image(systemName: "arrow.down.circle.fill") + .font(.body) + .foregroundStyle(.green) + } else if isDownloading { + ProgressView() + .scaleEffect(0.8) + } + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + // Download/Delete action + if isDownloaded { + Button(role: .destructive) { + Task { + try? downloadService.deleteDownload( + slug: audioPlayer.slug, + chapter: chapter.number, + voice: audioPlayer.voice + ) + } + } label: { + Label("Delete", systemImage: "trash") + } + } else if isDownloading { + Button(role: .destructive) { + downloadService.cancelDownload( + slug: audioPlayer.slug, + chapter: chapter.number, + voice: audioPlayer.voice + ) + } label: { + Label("Cancel", systemImage: "xmark") + } + } else { + Button { + Task { + try? await downloadService.download( + slug: audioPlayer.slug, + chapter: chapter.number, + voice: audioPlayer.voice + ) + } + } label: { + Label("Download", systemImage: "arrow.down.circle") + } + .tint(.blue) + } + } + } +} + +// MARK: - Right-edge jump bar +// A thin vertical strip on the right side of the sheet with block labels. +// Tapping or dragging a label jumps the list to that block instantly — +// exactly like the Contacts A–Z bar or Apple Books chapter scrubber. + +private struct JumpBar: View { + let labels: [String] + let currentChapter: Int + let groups: [(label: String, chapters: [ChapterIndexBrief])] + let onSelect: (String) -> Void + + @State private var isDragging = false + + /// Short display label for each block: "1–100" → "1" etc. + private func shortLabel(_ full: String) -> String { + full.components(separatedBy: "–").first ?? full + } + + /// Which block contains the currently playing chapter. + private var currentBlock: String? { + groups.first(where: { g in g.chapters.contains(where: { $0.number == currentChapter }) })?.label + } + + var body: some View { + VStack(spacing: 0) { + ForEach(labels, id: \.self) { label in + let isCurrent = label == currentBlock + Text(shortLabel(label)) + .font(.system(size: 10, weight: isCurrent ? .bold : .regular)) + .foregroundStyle(isCurrent ? Color.amber : Color.secondary) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + .onTapGesture { onSelect(label) } + } + } + .padding(.vertical, 6) + .background( + Capsule() + .fill(.ultraThinMaterial) + .shadow(color: .black.opacity(0.15), radius: 4) + ) + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .local) + .onChanged { value in + isDragging = true + let itemHeight: CGFloat = 28 + let index = Int(value.location.y / itemHeight) + let clamped = max(0, min(labels.count - 1, index)) + onSelect(labels[clamped]) + } + .onEnded { _ in isDragging = false } + ) + .animation(.easeInOut(duration: 0.15), value: isDragging) + } +} + +// MARK: - Custom seek slider +// A thicker, rounded-thumb slider that matches the amber design language. + +struct PlayerSlider: View { + @Binding var value: Double + let range: ClosedRange + + @State private var isDragging = false + @State private var didFireHaptic = false + + var body: some View { + GeometryReader { geo in + let width = geo.size.width + let fraction = (value - range.lowerBound) / (range.upperBound - range.lowerBound) + let clampedFraction = max(0, min(1, fraction)) + let filled = width * clampedFraction + let thumbSize: CGFloat = isDragging ? 26 : 20 + let trackHeight: CGFloat = isDragging ? 5 : 4 + + ZStack(alignment: .leading) { + // Track background + Capsule() + .fill(Color.white.opacity(0.2)) + .frame(height: trackHeight) + + // Filled portion — amber gradient + Capsule() + .fill( + LinearGradient( + colors: [Color.amber.opacity(0.9), Color.amber], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: max(filled, thumbSize / 2), height: trackHeight) + + // Thumb + Circle() + .fill(Color.white) + .frame(width: thumbSize, height: thumbSize) + .shadow(color: .black.opacity(0.3), radius: isDragging ? 6 : 3, y: isDragging ? 2 : 1) + .offset(x: max(0, filled - thumbSize / 2)) + .animation(.spring(response: 0.2, dampingFraction: 0.65), value: isDragging) + } + .frame(height: 36) // generous touch target + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { drag in + if !isDragging { + isDragging = true + if !didFireHaptic { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + didFireHaptic = true + } + } + let raw = drag.location.x / width + let clamped = max(0, min(1, raw)) + value = range.lowerBound + clamped * (range.upperBound - range.lowerBound) + } + .onEnded { _ in + isDragging = false + didFireHaptic = false + } + ) + } + .frame(height: 36) + } +} + +// MARK: - Isolated mini-player progress bar background + +private struct MiniPlayerProgressBar: View { + @ObservedObject var progress: PlaybackProgress + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 40) + .fill(Color.white.opacity(0.2)) + RoundedRectangle(cornerRadius: 6) + .fill(Color.amber.opacity(0.3)) + .frame(width: max(0, geo.size.width * fraction)) + } + .clipShape(RoundedRectangle(cornerRadius: 40)) + } + } + + private var fraction: CGFloat { + guard progress.duration > 0 else { return 0 } + return CGFloat(progress.currentTime / progress.duration) + } +} + +// MARK: - Isolated progress section (seek bar + timestamps) +// Observes PlaybackProgress directly so the 0.5-second time ticks only +// invalidate this small view — not the menus or controls around it. + +private struct PlayerProgressSection: View { + @ObservedObject var progress: PlaybackProgress + let onSeek: (Double) -> Void + + var body: some View { + VStack(spacing: 4) { + PlayerSlider( + value: Binding( + get: { progress.currentTime }, + set: { onSeek($0) } + ), + range: 0...max(progress.duration, 1) + ) + HStack { + Text(formatTime(progress.currentTime)) + Spacer() + Text("-" + formatTime(progress.duration - progress.currentTime)) + } + .font(.caption.monospacedDigit()) + .foregroundStyle(.white.opacity(0.5)) + } + .padding(.horizontal, 28) + } + + private func formatTime(_ seconds: Double) -> String { + guard seconds.isFinite, seconds >= 0 else { return "0:00" } + let s = Int(seconds) + return "\(s / 60):\(String(format: "%02d", s % 60))" + } +} + +// MARK: - Isolated play/pause button +// Observes PlaybackProgress so isPlaying changes only re-render this button. + +private struct PlayerPlayPauseButton: View { + @ObservedObject var progress: PlaybackProgress + let isGenerating: Bool + let onToggle: () -> Void + + @State private var isPressed = false + + var body: some View { + Button { + onToggle() + } label: { + ZStack { + // Outer glow ring (visible while playing) + Circle() + .fill(Color.amber.opacity(progress.isPlaying ? 0.18 : 0)) + .frame(width: 80, height: 80) + .animation(.easeInOut(duration: 0.35), value: progress.isPlaying) + + // Main fill circle + Circle() + .fill( + LinearGradient( + colors: [Color.amber.opacity(0.9), Color.amber.opacity(0.65)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 64, height: 64) + .shadow(color: Color.amber.opacity(0.45), radius: 12, y: 4) + .scaleEffect(isPressed ? 0.92 : 1.0) + .animation(.spring(response: 0.2, dampingFraction: 0.6), value: isPressed) + + if isGenerating { + ProgressView() + .tint(.white) + .scaleEffect(1.2) + } else { + Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(.white) + .offset(x: progress.isPlaying ? 0 : 2) + .contentTransition(.symbolEffect(.replace.downUp)) + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .disabled(isGenerating) + ._onButtonGesture(pressing: { isPressed = $0 }, perform: {}) + } +} + +// MARK: - Isolated mini-player play/pause button + +private struct MiniPlayerPlayPauseButton: View { + @ObservedObject var progress: PlaybackProgress + let onToggle: () -> Void + + var body: some View { + Button { onToggle() } label: { + Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +// MARK: - Download Management Sheet + +struct DownloadManagementSheet: View { + let chapters: [ChapterIndexBrief] + let slug: String + @Binding var voice: String + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var downloadService: AudioDownloadService + @EnvironmentObject var authStore: AuthStore + @State private var showingDeleteAll = false + @State private var isDownloadingAll = false + @State private var showingVoiceSelector = false + @State private var showingRangeSelector = false + @StateObject private var voiceVM = VoiceSelectionViewModel() + + // Range selection state + @State private var rangeStart: Int = 1 + @State private var rangeEnd: Int = 1 + + private var downloadedChapters: [ChapterIndexBrief] { + chapters.filter { ch in + downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: voice) + } + } + + private var notDownloadedChapters: [ChapterIndexBrief] { + chapters.filter { ch in + !downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: voice) + } + } + + var body: some View { + NavigationStack { + List { + // Voice info section + Section { + Button { + showingVoiceSelector = true + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Download Voice") + .font(.subheadline) + .foregroundStyle(.secondary) + HStack(spacing: 6) { + Text(voiceLabel(voice)) + .font(.body.weight(.semibold)) + .foregroundStyle(.primary) + if BookVoicePreferences.shared.hasOverride(for: slug) { + Text("(Custom)") + .font(.caption) + .foregroundStyle(.blue) + } + } + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + } footer: { + Text("Tap to change voice. All downloads will use the selected voice for this book.") + .font(.caption) + } + + Section { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("\(downloadedChapters.count) Downloaded") + .font(.title2.bold()) + Text("\(notDownloadedChapters.count) remaining") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + // Circular completion indicator + ZStack { + Circle() + .stroke(Color(.systemGray5), lineWidth: 4) + Circle() + .trim(from: 0, to: chapters.isEmpty ? 0 : CGFloat(downloadedChapters.count) / CGFloat(chapters.count)) + .stroke(Color.green, style: StrokeStyle(lineWidth: 4, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.4), value: downloadedChapters.count) + Text("\(chapters.isEmpty ? 0 : Int(Double(downloadedChapters.count) / Double(chapters.count) * 100))%") + .font(.caption2.bold()) + .foregroundStyle(.secondary) + } + .frame(width: 44, height: 44) + } + + HStack(spacing: 10) { + if notDownloadedChapters.count > 0 { + Button { + showingRangeSelector = true + } label: { + Label("Range", systemImage: "list.number") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.blue) + + Button { + downloadAllRemaining() + } label: { + HStack(spacing: 6) { + if isDownloadingAll { + ProgressView().scaleEffect(0.75) + } else { + Image(systemName: "arrow.down.circle.fill") + } + Text("All (\(notDownloadedChapters.count))") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .disabled(isDownloadingAll) + } + + if downloadedChapters.count > 0 { + Button { + showingDeleteAll = true + } label: { + Label("Delete All", systemImage: "trash") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.red) + } + } + } + .padding(.vertical, 8) + } + + if downloadedChapters.count > 0 { + Section { + ForEach(downloadedChapters, id: \.number) { chapter in + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Chapter \(chapter.number)") + .font(.subheadline.weight(.semibold)) + Text(chapter.title.strippingTrailingDate()) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + .onDelete { indexSet in + deleteChapters(at: indexSet, from: downloadedChapters) + } + } header: { + Text("Downloaded (\(downloadedChapters.count))") + } + } + } + .navigationTitle("Manage Downloads") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + } + } + .confirmationDialog( + "Delete all downloads?", + isPresented: $showingDeleteAll, + titleVisibility: .visible + ) { + Button("Delete All Downloads", role: .destructive) { + deleteAllDownloads() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will delete \(downloadedChapters.count) downloaded chapters. You can re-download them later.") + } + .sheet(isPresented: $showingVoiceSelector) { + VoiceSelectorSheet( + selectedVoice: voice, + slug: slug, + voiceVM: voiceVM, + onSelectVoice: { newVoice in + voice = newVoice + // Save per-book voice override + BookVoicePreferences.shared.setVoice(newVoice, for: slug) + showingVoiceSelector = false + } + ) + } + .sheet(isPresented: $showingRangeSelector) { + RangeDownloadSheet( + chapters: notDownloadedChapters, + slug: slug, + voice: voice, + onDownload: { start, end in + downloadRange(from: start, to: end) + showingRangeSelector = false + } + ) + .presentationDetents([.medium]) + } + } + } + + private func downloadRange(from start: Int, to end: Int) { + isDownloadingAll = true + Task { + let chaptersToDownload = notDownloadedChapters.filter { ch in + ch.number >= start && ch.number <= end + } + for chapter in chaptersToDownload { + try? await downloadService.download(slug: slug, chapter: chapter.number, voice: voice) + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s + } + await MainActor.run { + isDownloadingAll = false + } + } + } + + private func downloadAllRemaining() { + isDownloadingAll = true + Task { + for chapter in notDownloadedChapters { + try? await downloadService.download(slug: slug, chapter: chapter.number, voice: voice) + // Small delay to avoid overwhelming the API + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s + } + await MainActor.run { + isDownloadingAll = false + } + } + } + + private func deleteChapters(at indexSet: IndexSet, from chapters: [ChapterIndexBrief]) { + for index in indexSet { + let chapter = chapters[index] + try? downloadService.deleteDownload(slug: slug, chapter: chapter.number, voice: voice) + } + } + + private func deleteAllDownloads() { + for chapter in downloadedChapters { + try? downloadService.deleteDownload(slug: slug, chapter: chapter.number, voice: voice) + } + } + + // Voice label formatting (matches VoiceSelectionViewModel) + private func voiceLabel(_ voice: String) -> String { + let parts = voice.split(separator: "_") + guard parts.count >= 2 else { return voice } + + let prefix = String(parts[0]) + let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ") + + // Parse prefix for language/gender info + var info = "" + switch prefix { + case "af": info = "US F" + case "am": info = "US M" + case "bf": info = "UK F" + case "bm": info = "UK M" + default: info = prefix.uppercased() + } + + return "\(name) (\(info))" + } +} + +// MARK: - Voice Selector Panel (inline, expandable) + +private struct VoiceSelectorPanel: View { + let voices: [String] + let selectedVoice: String + let playingVoice: String? + let voiceVM: VoiceSelectionViewModel + let onSelectVoice: (String) -> Void + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Choose Voice") + .font(.caption.weight(.semibold)) + .foregroundStyle(.white.opacity(0.45)) + .textCase(.uppercase) + .tracking(0.8) + Spacer() + } + .padding(.horizontal, 18) + .padding(.top, 10) + .padding(.bottom, 6) + + // Voice list (scrollable) + ScrollView { + VStack(spacing: 0) { + ForEach(voices, id: \.self) { voice in + VoiceOptionRow( + voice: voice, + isSelected: selectedVoice == voice, + isPlaying: playingVoice == voice, + voiceLabel: voiceVM.voiceLabel(voice), + voiceId: voiceVM.voiceId(voice), + onSelect: { onSelectVoice(voice) }, + onPlaySample: { + Task { await voiceVM.playSample(voice) } + } + ) + + if voice != voices.last { + Divider() + .overlay(Color.white.opacity(0.08)) + .padding(.leading, 52) + } + } + } + } + .frame(maxHeight: 220) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14)) + .padding(.horizontal, 16) + + // Footer note + Text("New voice applies on next chapter") + .font(.caption2) + .foregroundStyle(.white.opacity(0.35)) + .padding(.top, 7) + .padding(.bottom, 10) + } + .background(.ultraThinMaterial) + } +} + +// MARK: - Voice Option Row (for inline panel) + +private struct VoiceOptionRow: View { + let voice: String + let isSelected: Bool + let isPlaying: Bool + let voiceLabel: String + let voiceId: String + let onSelect: () -> Void + let onPlaySample: () -> Void + + var body: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + onSelect() + } label: { + HStack(spacing: 12) { + // Selection indicator with spring bounce + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 18)) + .foregroundStyle(isSelected ? Color.amber : .white.opacity(0.25)) + .scaleEffect(isSelected ? 1.1 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.55), value: isSelected) + .frame(width: 24) + + // Voice info + VStack(alignment: .leading, spacing: 2) { + Text(voiceLabel) + .font(.subheadline) + .foregroundStyle(isSelected ? Color.amber : .white) + .fontWeight(isSelected ? .semibold : .regular) + .animation(.easeInOut(duration: 0.2), value: isSelected) + + Text(voiceId) + .font(.caption2) + .fontDesign(.monospaced) + .foregroundStyle(.white.opacity(0.4)) + } + + Spacer() + + // Play/Stop sample button + Button { + onPlaySample() + } label: { + Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill") + .font(.system(size: 24)) + .foregroundStyle(isPlaying ? Color.red : Color.amber.opacity(0.8)) + .contentTransition(.symbolEffect(.replace.downUp)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(isSelected ? Color.amber.opacity(0.08) : Color.clear) + .animation(.easeInOut(duration: 0.2), value: isSelected) + } +} + +// MARK: - Voice Selector Sheet (for download management) + +private struct VoiceSelectorSheet: View { + let selectedVoice: String + let slug: String + let voiceVM: VoiceSelectionViewModel + let onSelectVoice: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var authStore: AuthStore + + var body: some View { + NavigationStack { + List { + Section { + ForEach(voiceVM.voices, id: \.self) { voice in + Button { + onSelectVoice(voice) + } label: { + HStack(spacing: 12) { + // Checkmark for selected voice + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.blue) + .opacity(voice == selectedVoice ? 1 : 0) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(voiceVM.voiceLabel(voice)) + .font(.body) + .foregroundStyle(.primary) + Text(voice) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + + Spacer() + + // Play/stop button + Button { + Task { + await voiceVM.playSample(voice) + } + } label: { + Image(systemName: voiceVM.playingVoice == voice ? "stop.circle.fill" : "play.circle") + .font(.system(size: 24)) + .foregroundStyle(voiceVM.playingVoice == voice ? .red : .blue) + } + .buttonStyle(.plain) + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + } + } header: { + Text("Select Voice") + } footer: { + if BookVoicePreferences.shared.hasOverride(for: slug) { + Button("Reset to Global Voice") { + BookVoicePreferences.shared.removeVoice(for: slug) + onSelectVoice(authStore.settings.voice) + } + .font(.subheadline) + } + } + } + .navigationTitle("Download Voice") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + voiceVM.stopSample() + dismiss() + } + .fontWeight(.semibold) + } + } + .task { + if voiceVM.voices.isEmpty { + await voiceVM.loadVoices() + } + } + } + } +} + +// MARK: - Range Download Sheet + +private struct RangeDownloadSheet: View { + let chapters: [ChapterIndexBrief] + let slug: String + let voice: String + let onDownload: (Int, Int) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var startChapter: Int + @State private var endChapter: Int + + init(chapters: [ChapterIndexBrief], slug: String, voice: String, onDownload: @escaping (Int, Int) -> Void) { + self.chapters = chapters + self.slug = slug + self.voice = voice + self.onDownload = onDownload + + let firstChapter = chapters.first?.number ?? 1 + let lastChapter = chapters.last?.number ?? 1 + _startChapter = State(initialValue: firstChapter) + _endChapter = State(initialValue: min(firstChapter + 9, lastChapter)) + } + + private var chapterRange: [Int] { + guard let first = chapters.first?.number, + let last = chapters.last?.number else { return [] } + return Array(first...last) + } + + private var selectedCount: Int { + guard startChapter <= endChapter else { return 0 } + return endChapter - startChapter + 1 + } + + var body: some View { + NavigationStack { + Form { + Section { + Picker("Start Chapter", selection: $startChapter) { + ForEach(chapterRange, id: \.self) { num in + Text("Chapter \(num)").tag(num) + } + } + + Picker("End Chapter", selection: $endChapter) { + ForEach(chapterRange.filter { $0 >= startChapter }, id: \.self) { num in + Text("Chapter \(num)").tag(num) + } + } + } header: { + Text("Select Range") + } footer: { + Text("\(selectedCount) chapters will be downloaded") + } + + Section { + Button { + onDownload(startChapter, endChapter) + dismiss() + } label: { + HStack { + Spacer() + Image(systemName: "arrow.down.circle.fill") + Text("Download \(selectedCount) Chapters") + Spacer() + } + } + .disabled(selectedCount == 0) + } + } + .navigationTitle("Download Range") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Cancel") { dismiss() } + } + } + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift b/ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift new file mode 100644 index 0000000..79aff03 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift @@ -0,0 +1,341 @@ +import SwiftUI +import PhotosUI +import Kingfisher + +// MARK: - AvatarNavButton +// Drop this into any NavigationStack toolbar to get an avatar button that opens the account sheet. +// +// Usage: +// .toolbar { AvatarToolbarButton() } + +struct AvatarToolbarButton: View { + @EnvironmentObject private var authStore: AuthStore + @State private var showAccount = false + + var body: some View { + Button { + showAccount = true + } label: { + AvatarThumb(urlString: authStore.user?.avatarURL, size: 30) + } + .sheet(isPresented: $showAccount) { + AccountMenuSheet() + } + } +} + +// MARK: - AvatarThumb +// Reusable small circular avatar (used by both toolbar button and the sheet header). + +struct AvatarThumb: View { + let urlString: String? + let size: CGFloat + + var body: some View { + Group { + if let str = urlString, let url = URL(string: str) { + KFImage(url) + .placeholder { placeholderCircle } + .resizable() + .scaledToFill() + } else { + placeholderCircle + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.amber.opacity(0.6), lineWidth: 1.5)) + } + + private var placeholderCircle: some View { + Circle() + .fill(Color(.systemGray4)) + .overlay( + Image(systemName: "person.fill") + .font(.system(size: size * 0.5)) + .foregroundStyle(Color.amber) + ) + } +} + +// MARK: - AccountMenuSheet + +struct AccountMenuSheet: View { + @EnvironmentObject private var authStore: AuthStore + @StateObject private var vm = ProfileViewModel() + @Environment(\.dismiss) private var dismiss + + @State private var showChangePassword = false + + // Avatar upload + @State private var photoPickerItem: PhotosPickerItem? + @State private var pendingCropImage: UIImage? + @State private var avatarURL: String? = nil + @State private var avatarUploading = false + @State private var avatarError: String? + + var body: some View { + NavigationStack { + List { + // ── User header ──────────────────────────────────────────── + Section { + HStack(spacing: 16) { + avatarPicker + VStack(alignment: .leading, spacing: 3) { + Text(authStore.user?.username ?? "") + .font(.headline) + Text(authStore.user?.role.capitalized ?? "") + .font(.caption) + .foregroundStyle(.secondary) + if let err = avatarError { + Text(err) + .font(.caption2) + .foregroundStyle(.red) + } + } + } + .padding(.vertical, 6) + } + + // ── Reading settings ─────────────────────────────────────── + Section("Reading Settings") { + voicePicker + speedSlider + Toggle("Auto-advance chapter", isOn: Binding( + get: { authStore.settings.autoNext }, + set: { newVal in + Task { + var s = authStore.settings + s.autoNext = newVal + await authStore.saveSettings(s) + } + } + )) + .tint(.amber) + } + + // ── Sessions ─────────────────────────────────────────────── + Section("Active Sessions") { + if vm.sessionsLoading { + ProgressView() + } else { + ForEach(vm.sessions) { session in + SessionRow(session: session) { + Task { await vm.revokeSession(id: session.id) } + } + } + } + } + + // ── Account ──────────────────────────────────────────────── + Section("Account") { + Button("Change Password") { showChangePassword = true } + Button("Sign Out", role: .destructive) { + dismiss() + Task { await authStore.logout() } + } + } + } + .navigationTitle("Account") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + } + } + .task { await vm.loadSessions() } + .sheet(isPresented: $showChangePassword) { + ChangePasswordView() + } + .sheet(item: Binding( + get: { pendingCropImage.map { CropImageItem(image: $0) } }, + set: { if $0 == nil { pendingCropImage = nil } } + )) { item in + AvatarCropView(image: item.image) { croppedData in + pendingCropImage = nil + Task { await uploadCroppedData(croppedData) } + } onCancel: { + pendingCropImage = nil + } + } + .errorAlert($vm.error) + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + + // MARK: - Avatar upload + + private func loadImageForCrop(_ item: PhotosPickerItem) async { + guard let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) else { + avatarError = "Could not read image" + return + } + pendingCropImage = image + } + + private func uploadCroppedData(_ data: Data) async { + avatarUploading = true + avatarError = nil + defer { avatarUploading = false } + do { + let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg") + avatarURL = url + await authStore.validateToken() + } catch { + avatarError = "Upload failed: \(error.localizedDescription)" + } + } + + // MARK: - Avatar picker + + @ViewBuilder + private var avatarPicker: some View { + PhotosPicker(selection: $photoPickerItem, + matching: .images, + photoLibrary: .shared()) { + ZStack { + Circle() + .fill(Color(.systemGray5)) + .frame(width: 72, height: 72) + + if avatarUploading { + ProgressView() + .frame(width: 72, height: 72) + } else if let urlStr = avatarURL ?? authStore.user?.avatarURL, + let url = URL(string: urlStr) { + KFImage(url) + .placeholder { + Image(systemName: "person.circle.fill") + .font(.system(size: 52)) + .foregroundStyle(.amber) + } + .resizable() + .scaledToFill() + .frame(width: 72, height: 72) + .clipShape(Circle()) + } else { + Image(systemName: "person.circle.fill") + .font(.system(size: 52)) + .foregroundStyle(.amber) + .frame(width: 72, height: 72) + } + + // Camera badge + if !avatarUploading { + VStack { + Spacer() + HStack { + Spacer() + ZStack { + Circle().fill(Color.amber).frame(width: 22, height: 22) + Image(systemName: "camera.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.black) + } + .offset(x: 2, y: 2) + } + } + .frame(width: 72, height: 72) + } + } + } + .buttonStyle(.plain) + .onChange(of: photoPickerItem) { _, item in + guard let item else { return } + Task { await loadImageForCrop(item) } + } + } + + // MARK: - Voice picker + + @ViewBuilder + private var voicePicker: some View { + Picker("TTS Voice", selection: Binding( + get: { authStore.settings.voice }, + set: { newVoice in + Task { + var s = authStore.settings + s.voice = newVoice + await authStore.saveSettings(s) + } + } + )) { + if vm.voices.isEmpty { + Text("Default").tag("af_bella") + } else { + ForEach(vm.voices, id: \.self) { v in + Text(v).tag(v) + } + } + } + .task { await vm.loadVoices() } + } + + // MARK: - Speed slider + + @ViewBuilder + private var speedSlider: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Playback Speed") + Spacer() + Text("\(authStore.settings.speed, specifier: "%.1f")×") + .foregroundStyle(.secondary) + } + Slider( + value: Binding( + get: { authStore.settings.speed }, + set: { newSpeed in + Task { + var s = authStore.settings + s.speed = newSpeed + await authStore.saveSettings(s) + } + } + ), + in: 0.5...2.0, step: 0.25 + ) + .tint(.amber) + } + } +} + +// MARK: - Session row (local copy — mirrors ProfileView.SessionRow) + +private struct SessionRow: View { + let session: UserSession + let onRevoke: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "iphone") + Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent) + .font(.subheadline) + .lineLimit(1) + Spacer() + if session.isCurrent { + Text("This device") + .font(.caption2.bold()) + .foregroundStyle(.amber) + } else { + Button("Revoke", role: .destructive, action: onRevoke) + .font(.caption) + } + } + Text("Last seen: \(session.lastSeen.prefix(10))") + .font(.caption2) + .foregroundStyle(.secondary) + } + } +} + +// MARK: - CropImageItem (Identifiable wrapper for the sheet) + +private struct CropImageItem: Identifiable { + let id = UUID() + let image: UIImage +} diff --git a/ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift b/ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift new file mode 100644 index 0000000..74dbd67 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift @@ -0,0 +1,270 @@ +import SwiftUI + +// MARK: - AvatarCropView +// A sheet that lets the user pan and pinch a photo to fill a 1:1 circular crop region. +// Call: .sheet(item: $cropImage) { AvatarCropView(image: $0.image, onConfirm: { croppedData in … }) } + +struct AvatarCropView: View { + let image: UIImage + let onConfirm: (Data) -> Void + let onCancel: () -> Void + + // Crop circle diameter (points) + private let cropSize: CGFloat = 280 + + // Pan/zoom state — all in screen points, relative to the image's natural fill-fitted frame + @State private var scale: CGFloat = 1.0 + @State private var lastScale: CGFloat = 1.0 + @State private var offset: CGSize = .zero + @State private var lastOffset: CGSize = .zero + + // Container size captured from GeometryReader + @State private var containerSize: CGSize = .zero + + var body: some View { + NavigationStack { + GeometryReader { geo in + ZStack { + Color.black.ignoresSafeArea() + + // Draggable / pinchable image + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: geo.size.width, height: geo.size.height) + .scaleEffect(scale, anchor: .center) + .offset(offset) + .gesture( + SimultaneousGesture( + MagnificationGesture() + .onChanged { value in + let proposed = lastScale * value + scale = max(minScale(in: geo.size), proposed) + } + .onEnded { _ in + lastScale = scale + clampOffset(in: geo.size) + lastOffset = offset + }, + DragGesture() + .onChanged { value in + let proposed = CGSize( + width: lastOffset.width + value.translation.width, + height: lastOffset.height + value.translation.height + ) + offset = clampedOffset(proposed, in: geo.size) + } + .onEnded { _ in + lastOffset = offset + } + ) + ) + .clipped() + + // Dim overlay with transparent crop circle cut out + CropOverlay(cropSize: cropSize, containerSize: geo.size) + .allowsHitTesting(false) + } + .onAppear { + containerSize = geo.size + fitImageInitially(in: geo.size) + } + } + .navigationTitle("Crop Photo") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel", action: onCancel) + .foregroundStyle(.white) + } + ToolbarItem(placement: .topBarTrailing) { + Button("Use Photo") { + confirmCrop() + } + .fontWeight(.semibold) + .foregroundStyle(.amber) + } + } + .toolbarColorScheme(.dark, for: .navigationBar) + } + } + + // MARK: - Initial fit + + private func fitImageInitially(in size: CGSize) { + // The image is displayed with .scaledToFill() in the container (size). + // That means one dimension equals the container and the other overflows. + // We want the image to be just large enough that the crop circle is fully + // covered — i.e. the fill-fitted image's shorter displayed dimension >= cropSize. + // + // .scaledToFill fills the container, so the image already covers the container. + // The minimum scale that covers the crop square is therefore 1.0 (image already + // fills container which is >= cropSize on both axes). + // We keep scale = 1.0 and centre the offset. + scale = 1.0 + lastScale = 1.0 + offset = .zero + lastOffset = .zero + } + + // MARK: - Clamping helpers + + /// Minimum scale: the image (at .scaledToFill in container) must cover the crop square. + /// At scale=1 the image already fills the container; cropSize <= container dimension, + /// so 1.0 is always sufficient. We cap at 1.0 to prevent zooming out below fill. + private func minScale(in containerSize: CGSize) -> CGFloat { + return 1.0 + } + + /// The displayed (fill-fitted) image size in the container at the given user scale. + private func displayedImageSize(in containerSize: CGSize, userScale: CGFloat) -> CGSize { + let imgAspect = image.size.width / image.size.height + let containerAspect = containerSize.width / containerSize.height + + // .scaledToFill base size before user scale + let baseWidth: CGFloat + let baseHeight: CGFloat + if imgAspect > containerAspect { + // image is wider — height fills container + baseHeight = containerSize.height + baseWidth = baseHeight * imgAspect + } else { + // image is taller — width fills container + baseWidth = containerSize.width + baseHeight = baseWidth / imgAspect + } + return CGSize(width: baseWidth * userScale, height: baseHeight * userScale) + } + + /// Maximum offset so the crop square is always covered by the image. + private func clampedOffset(_ proposed: CGSize, in containerSize: CGSize) -> CGSize { + let displayed = displayedImageSize(in: containerSize, userScale: scale) + // Half of how much the image overflows the container on each axis + let maxX = max(0, (displayed.width - cropSize) / 2) + let maxY = max(0, (displayed.height - cropSize) / 2) + return CGSize( + width: min(maxX, max(-maxX, proposed.width)), + height: min(maxY, max(-maxY, proposed.height)) + ) + } + + private func clampOffset(in containerSize: CGSize) { + offset = clampedOffset(offset, in: containerSize) + } + + // MARK: - Crop + + private func confirmCrop() { + let size = containerSize.width > 0 ? containerSize : CGSize(width: 390, height: 844) + let outputSize = CGSize(width: 400, height: 400) + + // --- Step 1: compute the fill-fitted base display size --- + let imgAspect = image.size.width / image.size.height + let containerAspect = size.width / size.height + + let baseDisplayW: CGFloat + let baseDisplayH: CGFloat + if imgAspect > containerAspect { + baseDisplayH = size.height + baseDisplayW = baseDisplayH * imgAspect + } else { + baseDisplayW = size.width + baseDisplayH = baseDisplayW / imgAspect + } + + // Displayed image size after user zoom + let displayW = baseDisplayW * scale + let displayH = baseDisplayH * scale + + // --- Step 2: the crop square centre is the container centre --- + // The image centre (after offset) in container coords: + let imageCentreX = size.width / 2 + offset.width + let imageCentreY = size.height / 2 + offset.height + + // Top-left of the crop square in container coords: + let cropOriginX = (size.width - cropSize) / 2 + let cropOriginY = (size.height - cropSize) / 2 + + // Top-left of the crop square relative to the image's top-left in display space: + let imageOriginX = imageCentreX - displayW / 2 + let imageOriginY = imageCentreY - displayH / 2 + + let cropInImageX = cropOriginX - imageOriginX // pixels in display space + let cropInImageY = cropOriginY - imageOriginY + + // --- Step 3: convert display-space coords to image pixel coords --- + let displayToPixelX = image.size.width / displayW + let displayToPixelY = image.size.height / displayH + + let pixelX = cropInImageX * displayToPixelX + let pixelY = cropInImageY * displayToPixelY + let pixelW = cropSize * displayToPixelX + let pixelH = cropSize * displayToPixelY + + let cropRect = CGRect(x: pixelX, y: pixelY, width: pixelW, height: pixelH) + .intersection(CGRect(origin: .zero, size: image.size)) + + guard cropRect.width > 0, cropRect.height > 0 else { + // Fallback: use entire image + if let jpeg = image.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) } + return + } + + // --- Step 4: render cropped region into 400×400 --- + let renderer = UIGraphicsImageRenderer(size: outputSize) + let cropped = renderer.image { _ in + // Draw only the cropRect portion of the image scaled to fill outputSize + let destRect = CGRect(origin: .zero, size: outputSize) + // UIImage.draw(in:) draws the full image; we use CGImage cropping instead + if let cgImg = image.cgImage?.cropping(to: cropRect) { + let croppedUI = UIImage(cgImage: cgImg, scale: image.scale, orientation: image.imageOrientation) + croppedUI.draw(in: destRect) + } else { + image.draw(in: destRect) + } + } + + if let jpeg = cropped.jpegData(compressionQuality: 0.9) { + onConfirm(jpeg) + } + } +} + +// MARK: - Crop overlay + +private struct CropOverlay: View { + let cropSize: CGFloat + let containerSize: CGSize + + var body: some View { + Canvas { context, size in + // Fill entire canvas with semi-transparent black + context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55))) + // Cut out the crop circle in the centre + let origin = CGPoint( + x: (size.width - cropSize) / 2, + y: (size.height - cropSize) / 2 + ) + let cropRect = CGRect(origin: origin, size: CGSize(width: cropSize, height: cropSize)) + context.blendMode = .destinationOut + context.fill(Path(ellipseIn: cropRect), with: .color(.white)) + } + .compositingGroup() + .overlay { + // Amber circle border around the crop region + let origin = CGPoint( + x: (containerSize.width - cropSize) / 2, + y: (containerSize.height - cropSize) / 2 + ) + Circle() + .stroke(Color.amber.opacity(0.8), lineWidth: 2) + .frame(width: cropSize, height: cropSize) + .position( + x: origin.x + cropSize / 2, + y: origin.y + cropSize / 2 + ) + } + .frame(width: containerSize.width, height: containerSize.height) + .allowsHitTesting(false) + } +} diff --git a/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift new file mode 100644 index 0000000..b25508c --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift @@ -0,0 +1,362 @@ +import SwiftUI +import PhotosUI +import Kingfisher + +struct ProfileView: View { + @EnvironmentObject var authStore: AuthStore + @StateObject private var vm = ProfileViewModel() + @State private var showChangePassword = false + @State private var showVoiceSelection = false + @State private var showDownloads = false + + // Avatar upload state + @State private var photoPickerItem: PhotosPickerItem? + @State private var pendingCropImage: UIImage? // image waiting to be cropped + @State private var avatarURL: String? = nil + @State private var avatarUploading = false + @State private var avatarError: String? + + var body: some View { + NavigationStack { + List { + // ── User header ──────────────────────────────────────────── + Section { + HStack(spacing: 16) { + avatarPicker + VStack(alignment: .leading, spacing: 3) { + Text(authStore.user?.username ?? "") + .font(.headline) + Text(authStore.user?.role.capitalized ?? "") + .font(.caption) + .foregroundStyle(.secondary) + if let err = avatarError { + Text(err) + .font(.caption2) + .foregroundStyle(.red) + } + } + } + .padding(.vertical, 6) + } + + // ── Reading settings ─────────────────────────────────────── + Section("Reading Settings") { + voicePicker + speedSlider + Toggle("Auto-advance chapter", isOn: Binding( + get: { authStore.settings.autoNext }, + set: { newVal in + Task { + var s = authStore.settings + s.autoNext = newVal + await authStore.saveSettings(s) + } + } + )) + .tint(.amber) + + Button { + showDownloads = true + } label: { + HStack { + Text("Downloads") + .foregroundStyle(.primary) + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + + // ── Sessions ─────────────────────────────────────────────── + Section("Active Sessions") { + if vm.sessionsLoading { + ProgressView() + } else { + ForEach(vm.sessions) { session in + SessionRow(session: session) { + Task { await vm.revokeSession(id: session.id) } + } + } + } + } + + // ── Account ──────────────────────────────────────────────── + Section("Account") { + Button("Change Password") { showChangePassword = true } + Button("Sign Out", role: .destructive) { + Task { await authStore.logout() } + } + } + } + .navigationTitle("Profile") + .task { + await vm.loadSessions() + } + + .sheet(isPresented: $showChangePassword) { + ChangePasswordView() + } + .sheet(isPresented: $showVoiceSelection) { + VoiceSelectionView(currentVoice: authStore.settings.voice) + } + .sheet(isPresented: $showDownloads) { + DownloadsView() + } + .sheet(item: Binding( + get: { pendingCropImage.map { CropImageItem(image: $0) } }, + set: { if $0 == nil { pendingCropImage = nil } } + )) { item in + AvatarCropView(image: item.image) { croppedData in + pendingCropImage = nil + Task { await uploadCroppedData(croppedData) } + } onCancel: { + pendingCropImage = nil + } + } + .errorAlert($vm.error) + } + } + + // MARK: - Avatar upload + + /// Step 1: Load the raw image from the picker and show the crop sheet. + private func loadImageForCrop(_ item: PhotosPickerItem) async { + guard let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) else { + avatarError = "Could not read image" + return + } + pendingCropImage = image + } + + /// Step 2: Called by AvatarCropView once the user confirms. Upload the cropped JPEG. + private func uploadCroppedData(_ data: Data) async { + avatarUploading = true + avatarError = nil + defer { avatarUploading = false } + do { + let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg") + avatarURL = url + // Refresh user record so the new avatar persists across sessions + await authStore.validateToken() + } catch { + avatarError = "Upload failed: \(error.localizedDescription)" + } + } + + // MARK: - Avatar picker + + @ViewBuilder + private var avatarPicker: some View { + PhotosPicker(selection: $photoPickerItem, + matching: .images, + photoLibrary: .shared()) { + ZStack { + Circle() + .fill(Color(.systemGray5)) + .frame(width: 72, height: 72) + + if avatarUploading { + ProgressView() + .frame(width: 72, height: 72) + } else if let urlStr = avatarURL ?? authStore.user?.avatarURL, + let url = URL(string: urlStr) { + KFImage(url) + .placeholder { + Image(systemName: "person.circle.fill") + .font(.system(size: 52)) + .foregroundStyle(.amber) + } + .resizable() + .scaledToFill() + .frame(width: 72, height: 72) + .clipShape(Circle()) + } else { + Image(systemName: "person.circle.fill") + .font(.system(size: 52)) + .foregroundStyle(.amber) + .frame(width: 72, height: 72) + } + + // Camera overlay badge + if !avatarUploading { + VStack { + Spacer() + HStack { + Spacer() + ZStack { + Circle() + .fill(Color.amber) + .frame(width: 22, height: 22) + Image(systemName: "camera.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.black) + } + .offset(x: 2, y: 2) + } + } + .frame(width: 72, height: 72) + } + } + } + .buttonStyle(.plain) + .onChange(of: photoPickerItem) { _, item in + guard let item else { return } + Task { await loadImageForCrop(item) } + } + } + + // MARK: - Voice picker + + @ViewBuilder + private var voicePicker: some View { + Button { + showVoiceSelection = true + } label: { + HStack { + Text("TTS Voice") + .foregroundStyle(.primary) + Spacer() + Text(formatVoiceLabel(authStore.settings.voice)) + .foregroundStyle(.secondary) + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + + private func formatVoiceLabel(_ voice: String) -> String { + let parts = voice.split(separator: "_") + guard parts.count >= 2 else { return voice } + let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ") + return name + } + + // MARK: - Speed slider + + @ViewBuilder + private var speedSlider: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Playback Speed") + Spacer() + Text("\(authStore.settings.speed, specifier: "%.1f")×") + .foregroundStyle(.secondary) + } + Slider( + value: Binding( + get: { authStore.settings.speed }, + set: { newSpeed in + Task { + var s = authStore.settings + s.speed = newSpeed + await authStore.saveSettings(s) + } + } + ), + in: 0.5...2.0, step: 0.25 + ) + .tint(.amber) + } + } +} + +// MARK: - Session row + +private struct SessionRow: View { + let session: UserSession + let onRevoke: () -> Void + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "iphone") + Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent) + .font(.subheadline) + .lineLimit(1) + Spacer() + if session.isCurrent { + Text("This device") + .font(.caption2.bold()) + .foregroundStyle(.amber) + } else { + Button("Revoke", role: .destructive, action: onRevoke) + .font(.caption) + } + } + Text("Last seen: \(session.lastSeen.prefix(10))") + .font(.caption2) + .foregroundStyle(.secondary) + } + } +} + +// MARK: - Change password sheet + +struct ChangePasswordView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var authStore: AuthStore + @State private var current = "" + @State private var newPwd = "" + @State private var confirm = "" + @State private var isLoading = false + @State private var error: String? + @State private var success = false + + var body: some View { + NavigationStack { + Form { + Section { + SecureField("Current password", text: $current) + SecureField("New password", text: $newPwd) + SecureField("Confirm new password", text: $confirm) + } + if let error { + Text(error).foregroundStyle(.red).font(.caption) + } + if success { + Text("Password changed successfully").foregroundStyle(.green).font(.caption) + } + } + .navigationTitle("Change Password") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .topBarTrailing) { + Button("Save") { save() } + .disabled(isLoading || newPwd.count < 4 || newPwd != confirm) + } + } + } + } + + private func save() { + guard newPwd == confirm else { error = "Passwords do not match"; return } + isLoading = true + error = nil + Task { + do { + struct Body: Encodable { let currentPassword, newPassword: String } + let _: EmptyResponse = try await APIClient.shared.fetch( + "/api/auth/change-password", method: "POST", + body: Body(currentPassword: current, newPassword: newPwd) + ) + success = true + try? await Task.sleep(nanoseconds: 1_200_000_000) + dismiss() + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + } +} + +// MARK: - Crop image item (Identifiable wrapper for .sheet(item:)) + +private struct CropImageItem: Identifiable { + let id = UUID() + let image: UIImage +} diff --git a/ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift b/ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift new file mode 100644 index 0000000..f365079 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift @@ -0,0 +1,197 @@ +import SwiftUI + +struct UserProfileView: View { + let username: String + + @StateObject private var vm: UserProfileViewModel + @EnvironmentObject private var authStore: AuthStore + + init(username: String) { + self.username = username + _vm = StateObject(wrappedValue: UserProfileViewModel(username: username)) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if vm.isLoading && vm.profile == nil { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 60) + } else if let profile = vm.profile { + profileHeader(profile) + .padding(.bottom, 28) + + if !vm.currentlyReading.isEmpty { + ShelfHeader(title: "Currently Reading") + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 14) { + ForEach(vm.currentlyReading) { item in + NavigationLink(value: NavDestination.book(item.book.slug)) { + ProfileBookCard(item: item) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.bottom, 4) + } + .padding(.bottom, 28) + } + + if !vm.library.isEmpty { + ShelfHeader(title: "Library") + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 14) { + ForEach(vm.library) { item in + NavigationLink(value: NavDestination.book(item.book.slug)) { + ProfileBookCard(item: item) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.bottom, 4) + } + .padding(.bottom, 28) + } + + if vm.currentlyReading.isEmpty && vm.library.isEmpty && !vm.isLoading { + EmptyStateView( + icon: "books.vertical", + title: "No books yet", + message: "\(username) hasn't read anything yet." + ) + .frame(maxWidth: .infinity) + .padding(.top, 20) + } + } else if let err = vm.error { + EmptyStateView(icon: "exclamationmark.triangle", title: "Error", message: err) + .frame(maxWidth: .infinity) + .padding(.top, 60) + } + + Color.clear.frame(height: 20) + } + } + .navigationTitle("@\(username)") + .navigationBarTitleDisplayMode(.inline) + .task { await vm.load() } + .refreshable { await vm.load() } + .errorAlert($vm.error) + } + + // MARK: - Profile header + + @ViewBuilder + private func profileHeader(_ profile: PublicUserProfile) -> some View { + VStack(alignment: .center, spacing: 16) { + AvatarThumb(urlString: profile.avatarUrl, size: 80) + + VStack(spacing: 4) { + Text("@\(profile.username)") + .font(.title3.bold()) + if !profile.created.isEmpty { + Text("Joined \(shortDate(profile.created))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // Stats row + HStack(spacing: 32) { + VStack(spacing: 2) { + Text("\(profile.followerCount)") + .font(.subheadline.bold().monospacedDigit()) + Text("Followers") + .font(.caption2) + .foregroundStyle(.secondary) + } + VStack(spacing: 2) { + Text("\(profile.followingCount)") + .font(.subheadline.bold().monospacedDigit()) + Text("Following") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + // Follow button — only shown for other users (not self) + if !profile.isSelf && authStore.isAuthenticated { + Button { + Task { await vm.toggleSubscribe() } + } label: { + if vm.isTogglingSubscribe { + ProgressView().controlSize(.small) + .frame(width: 120, height: 34) + } else if profile.isSubscribed { + Label("Following", systemImage: "checkmark") + .font(.subheadline.bold()) + .frame(width: 120, height: 34) + } else { + Text("Follow") + .font(.subheadline.bold()) + .frame(width: 120, height: 34) + } + } + .buttonStyle(.borderedProminent) + .tint(profile.isSubscribed ? Color(.systemGray4) : .amber) + .disabled(vm.isTogglingSubscribe) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 24) + .padding(.horizontal) + } + + private func shortDate(_ iso: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ" + if let date = formatter.date(from: iso) { + let out = DateFormatter() + out.dateStyle = .medium + out.timeStyle = .none + return out.string(from: date) + } + return String(iso.prefix(10)) + } +} + +// MARK: - Book card for profile shelves + +private struct ProfileBookCard: View { + let item: PublicLibraryItem + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ZStack(alignment: .bottomLeading) { + AsyncCoverImage(url: item.book.cover) + .frame(width: 110, height: 158) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .shadow(color: .black.opacity(0.12), radius: 4, y: 2) + + // Chapter badge (if reading) + if let ch = item.lastChapter, ch > 0 { + Text("Ch.\(ch)") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.black.opacity(0.85)) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background(Capsule().fill(Color.amber)) + .padding(6) + } + } + + Text(item.book.title) + .font(.caption.bold()) + .lineLimit(2) + .frame(width: 110, alignment: .leading) + + Text(item.book.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: 110, alignment: .leading) + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/Profile/VoiceSelectionView.swift b/ios/LibNovel/LibNovel/Views/Profile/VoiceSelectionView.swift new file mode 100644 index 0000000..4b6b057 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Profile/VoiceSelectionView.swift @@ -0,0 +1,158 @@ +import SwiftUI + +struct VoiceSelectionView: View { + @StateObject private var vm = VoiceSelectionViewModel() + @EnvironmentObject var authStore: AuthStore + @Environment(\.dismiss) private var dismiss + + @State private var selectedVoice: String + + init(currentVoice: String) { + _selectedVoice = State(initialValue: currentVoice) + } + + var body: some View { + NavigationStack { + Group { + if vm.isLoading { + ProgressView("Loading voices...") + } else if let error = vm.error { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundStyle(.amber) + Text(error) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + .padding() + } else { + voiceList + } + } + .navigationTitle("Select Voice") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + saveAndDismiss() + } + .fontWeight(.semibold) + .disabled(selectedVoice == authStore.settings.voice) + } + } + .task { + await vm.loadVoices() + } + } + } + + // MARK: - Voice List + + @ViewBuilder + private var voiceList: some View { + List { + Section { + ForEach(vm.voices, id: \.self) { voice in + VoiceRow( + voice: voice, + isSelected: voice == selectedVoice, + isPlaying: vm.playingVoice == voice, + voiceLabel: vm.voiceLabel(voice), + voiceId: vm.voiceId(voice), + onSelect: { + vm.stopSample() + selectedVoice = voice + }, + onPlaySample: { + Task { + await vm.playSample(voice) + } + } + ) + } + } header: { + Text("Available Voices") + } footer: { + if selectedVoice != authStore.settings.voice { + Text("New voice will apply to next audio playback") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + // MARK: - Actions + + private func saveAndDismiss() { + Task { + var settings = authStore.settings + settings.voice = selectedVoice + await authStore.saveSettings(settings) + dismiss() + } + } +} + +// MARK: - Voice Row + +private struct VoiceRow: View { + let voice: String + let isSelected: Bool + let isPlaying: Bool + let voiceLabel: String + let voiceId: String + let onSelect: () -> Void + let onPlaySample: () -> Void + + var body: some View { + HStack(spacing: 12) { + // Selection checkmark + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 22)) + .foregroundStyle(isSelected ? .amber : .secondary.opacity(0.3)) + .frame(width: 28) + + // Voice info + VStack(alignment: .leading, spacing: 4) { + Text(voiceLabel) + .font(.body) + .fontWeight(isSelected ? .semibold : .regular) + + Text(voiceId) + .font(.caption) + .fontDesign(.monospaced) + .foregroundStyle(.secondary) + } + + Spacer() + + // Play sample button + Button { + onPlaySample() + } label: { + Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill") + .font(.system(size: 28)) + .foregroundStyle(isPlaying ? .red : .amber) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + .onTapGesture { + onSelect() + } + } +} + +// MARK: - Preview + +#Preview { + VoiceSelectionView(currentVoice: "af_bella") + .environmentObject(AuthStore()) +} diff --git a/ios/LibNovel/LibNovel/Views/Search/SearchView.swift b/ios/LibNovel/LibNovel/Views/Search/SearchView.swift new file mode 100644 index 0000000..51c80aa --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Search/SearchView.swift @@ -0,0 +1,286 @@ +import SwiftUI + +// MARK: - SearchView +// Dedicated search tab for intentional, fuzzy search. +// Live search as you type, shows recent searches when idle. + +struct SearchView: View { + @StateObject private var vm = SearchViewModel() + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + OfflineBanner() + + Group { + // ── Content ───────────────────────────────────────────────── + if vm.query.isEmpty && vm.results.isEmpty { + idleContent + } else if vm.isLoading && vm.results.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if vm.results.isEmpty && !vm.query.isEmpty { + EmptyStateView( + icon: "magnifyingglass", + title: "No results", + message: "Try a different title or author name." + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + resultsGrid + } + } + } + .navigationTitle("Search") + .searchable( + text: $vm.query, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search novels, authors…" + ) + .autocorrectionDisabled() + .onChange(of: vm.query) { _, newValue in + vm.onQueryChange(newValue) + } + .onSubmit(of: .search) { + vm.submitSearch() + } + .appNavigationDestination() + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 16) { + DownloadQueueButton() + AvatarToolbarButton() + } + } + } + } + } + + // MARK: - Idle screen (recent searches) + + @ViewBuilder + private var idleContent: some View { + if vm.recentSearches.isEmpty { + // Empty state - prompt to search + VStack(spacing: 16) { + Image(systemName: "magnifyingglass") + .font(.system(size: 56)) + .foregroundStyle(.secondary.opacity(0.5)) + Text("Search for novels") + .font(.title2.bold()) + .foregroundStyle(.primary) + Text("Find your next favorite book by title, author, or genre") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + // Recent searches list + ScrollView { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Recent Searches") + .font(.title3.bold()) + Spacer() + Button("Clear") { vm.clearRecent() } + .font(.subheadline) + .foregroundStyle(.amber) + } + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 12) + + ForEach(vm.recentSearches, id: \.self) { term in + Button { + vm.query = term + vm.submitSearch() + } label: { + HStack(spacing: 12) { + Image(systemName: "clock") + .foregroundStyle(.secondary) + .frame(width: 20) + Text(term) + .foregroundStyle(.primary) + Spacer() + Image(systemName: "arrow.up.left") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.horizontal) + .padding(.vertical, 12) + } + + if term != vm.recentSearches.last { + Divider() + .padding(.leading, 44) + } + } + } + } + } + } + + // MARK: - Results grid + + @ViewBuilder + private var resultsGrid: some View { + ScrollView { + VStack(spacing: 8) { + // Result count + HStack { + Text("\(vm.results.count) result\(vm.results.count == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal) + .padding(.top, 8) + + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 14), + GridItem(.flexible(), spacing: 14) + ], + spacing: 14 + ) { + ForEach(vm.results) { novel in + NavigationLink(value: NavDestination.book(novel.slug)) { + SearchNovelCard(novel: novel) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.bottom, 100) + } + } + } +} + +// MARK: - Search novel card (compact 2-column) + +private struct SearchNovelCard: View { + let novel: BrowseNovel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + AsyncCoverImage(url: novel.cover) + .frame(maxWidth: .infinity) + .aspectRatio(2/3, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .bookCoverZoomSource(slug: novel.slug) + + VStack(alignment: .leading, spacing: 3) { + Text(novel.title) + .font(.subheadline.bold()) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + + if !novel.author.isEmpty { + Text(novel.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) + } +} + +// MARK: - SearchViewModel + +@MainActor +final class SearchViewModel: ObservableObject { + @Published var query: String = "" + @Published var results: [BrowseNovel] = [] + @Published var isLoading = false + + // Persisted in UserDefaults (max 10 recent terms) + @Published var recentSearches: [String] = [] + + private let recentKey = "searchRecentTerms" + private var searchTask: Task? + + init() { + recentSearches = (UserDefaults.standard.stringArray(forKey: recentKey) ?? []) + } + + /// Called when query changes - implements debounced live search + func onQueryChange(_ newValue: String) { + // Cancel previous search task + searchTask?.cancel() + + // If query is empty, clear results + guard !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + results = [] + return + } + + // Debounce: wait 300ms before searching + searchTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) // 300ms + guard !Task.isCancelled else { return } + await runSearch(newValue) + } + } + + func submitSearch() { + let term = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !term.isEmpty else { return } + saveRecent(term) + // Cancel debounce and search immediately + searchTask?.cancel() + Task { await runSearch(term) } + } + + func clear() { + query = "" + results = [] + searchTask?.cancel() + } + + func clearRecent() { + recentSearches = [] + UserDefaults.standard.removeObject(forKey: recentKey) + } + + private func runSearch(_ term: String) async { + let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + results = [] + return + } + + isLoading = true + do { + let result = try await APIClient.shared.search(query: trimmed) + // Only update results if query hasn't changed + if query.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed { + results = result.results + } + } catch { + if !(error is CancellationError) { + results = [] + } + } + isLoading = false + } + + private func saveRecent(_ term: String) { + var list = recentSearches.filter { $0 != term } + list.insert(term, at: 0) + if list.count > 10 { list = Array(list.prefix(10)) } + recentSearches = list + UserDefaults.standard.set(list, forKey: recentKey) + } +} diff --git a/ios/LibNovel/LibNovelTests/LibNovelTests.swift b/ios/LibNovel/LibNovelTests/LibNovelTests.swift new file mode 100644 index 0000000..c076f88 --- /dev/null +++ b/ios/LibNovel/LibNovelTests/LibNovelTests.swift @@ -0,0 +1,9 @@ +import XCTest +@testable import LibNovel + +final class LibNovelTests: XCTestCase { + func testExample() throws { + // Placeholder — add real tests here + XCTAssert(true) + } +} diff --git a/ios/LibNovel/fastlane/Fastfile b/ios/LibNovel/fastlane/Fastfile new file mode 100644 index 0000000..be14624 --- /dev/null +++ b/ios/LibNovel/fastlane/Fastfile @@ -0,0 +1,36 @@ +default_platform(:ios) + +platform :ios do + desc "Build and upload to TestFlight" + lane :beta do + # Generate Xcode project from project.yml (one level up from fastlane/) + sh("cd .. && xcodegen generate --spec project.yml --project .") + + # Set build number from CI run number (passed as env var) + increment_build_number( + build_number: ENV["BUILD_NUMBER"] || "1", + xcodeproj: "LibNovel.xcodeproj" + ) + + # Build the app - signing settings are in project.yml Release config + build_app( + scheme: "LibNovel", + export_method: "app-store", + clean: true, + configuration: "Release", + export_options: { + method: "app-store", + teamID: "GHZXC6FVMU", + provisioningProfiles: { + "com.kalekber.LibNovel" => "LibNovel Distribution" + }, + signingStyle: "manual" + } + ) + + # Upload to TestFlight + upload_to_testflight( + skip_waiting_for_build_processing: true + ) + end +end diff --git a/ios/LibNovel/project.yml b/ios/LibNovel/project.yml new file mode 100644 index 0000000..c8483ed --- /dev/null +++ b/ios/LibNovel/project.yml @@ -0,0 +1,91 @@ +name: LibNovel +options: + bundleIdPrefix: com.kalekber + deploymentTarget: + iOS: "17.0" + xcodeVersion: "16.0" + generateEmptyDirectories: true + indentWidth: 4 + tabWidth: 4 + usesTabs: false + +settings: + base: + SWIFT_VERSION: "5.10" + ENABLE_PREVIEWS: YES + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + LIBNOVEL_BASE_URL: "https://v2.libnovel.kalekber.cc" + configs: + Debug: + SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG + Release: + SWIFT_ACTIVE_COMPILATION_CONDITIONS: "" + +packages: + # Async image loading with caching + Kingfisher: + url: https://github.com/onevcat/Kingfisher + from: "8.0.0" + +targets: + LibNovel: + type: application + platform: iOS + deploymentTarget: "17.0" + sources: + - path: LibNovel + excludes: + - "**/.DS_Store" + - "Resources/Info.plist" + resources: + - path: LibNovel/Resources/Assets.xcassets + dependencies: + - package: Kingfisher + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad + GENERATE_INFOPLIST_FILE: NO + INFOPLIST_FILE: LibNovel/Resources/Info.plist + configs: + Release: + CODE_SIGN_STYLE: Manual + DEVELOPMENT_TEAM: GHZXC6FVMU + CODE_SIGN_IDENTITY: "Apple Distribution" + PROVISIONING_PROFILE: "af592c3a-f60b-4ac1-a14f-30b8a206017f" + + LibNovelTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "17.0" + sources: + - path: LibNovelTests + dependencies: + - target: LibNovel + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel.tests + +schemes: + LibNovel: + build: + targets: + LibNovel: all + run: + config: Debug + environmentVariables: + LIBNOVEL_BASE_URL: + value: "https://v2.libnovel.kalekber.cc" + isEnabled: true + test: + config: Debug + targets: + - LibNovelTests + profile: + config: Release + analyze: + config: Debug + archive: + config: Release diff --git a/ios/LibNovel/test-build.sh b/ios/LibNovel/test-build.sh new file mode 100755 index 0000000..d652822 --- /dev/null +++ b/ios/LibNovel/test-build.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -e + +# Test script for local iOS build iteration +# Run from ios/LibNovel directory + +echo "=== Generating Xcode project ===" +xcodegen generate --spec project.yml --project . + +echo "" +echo "=== Listing available provisioning profiles ===" +ls -la ~/Library/MobileDevice/Provisioning\ Profiles/ || echo "No profiles found" + +echo "" +echo "=== Listing available signing identities ===" +security find-identity -v -p codesigning + +echo "" +echo "=== Attempting archive build ===" +xcodebuild archive \ + -project LibNovel.xcodeproj \ + -scheme LibNovel \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath ./build/LibNovel.xcarchive \ + -allowProvisioningUpdates \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="Apple Distribution" \ + DEVELOPMENT_TEAM="GHZXC6FVMU" + +echo "" +echo "=== Build succeeded! ===" diff --git a/ios/LibNovelV2/App/ContentView.swift b/ios/LibNovelV2/App/ContentView.swift new file mode 100644 index 0000000..824cf9a --- /dev/null +++ b/ios/LibNovelV2/App/ContentView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +// MARK: - Root content view +// Switches between AuthView (unauthenticated) and RootTabView (authenticated). + +struct ContentView: View { + @EnvironmentObject var authStore: AuthStore + + var body: some View { + Group { + if authStore.isAuthenticated { + RootTabView() + } else { + AuthView() + } + } + .animation(.easeInOut(duration: 0.25), value: authStore.isAuthenticated) + } +} diff --git a/ios/LibNovelV2/App/LibNovelV2App.swift b/ios/LibNovelV2/App/LibNovelV2App.swift new file mode 100644 index 0000000..80ace13 --- /dev/null +++ b/ios/LibNovelV2/App/LibNovelV2App.swift @@ -0,0 +1,21 @@ +import SwiftUI + +@main +struct LibNovelV2App: App { + @StateObject private var authStore = AuthStore() + @StateObject private var audioPlayer = AudioPlayerService() + @StateObject private var downloadService = AudioDownloadService.shared + @StateObject private var networkMonitor = NetworkMonitor() + @StateObject private var bookVoicePrefs = BookVoicePreferences.shared + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(authStore) + .environmentObject(audioPlayer) + .environmentObject(downloadService) + .environmentObject(networkMonitor) + .environmentObject(bookVoicePrefs) + } + } +} diff --git a/ios/LibNovelV2/App/RootTabView.swift b/ios/LibNovelV2/App/RootTabView.swift new file mode 100644 index 0000000..43e8d88 --- /dev/null +++ b/ios/LibNovelV2/App/RootTabView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +// MARK: - Root tab container with persistent mini-player overlay + +struct RootTabView: View { + @EnvironmentObject var authStore: AuthStore + @EnvironmentObject var audioPlayer: AudioPlayerService + + @State private var selectedTab: Tab = .home + @State private var showFullPlayer: Bool = false + @State private var readerIsActive: Bool = false + @State private var fullPlayerDragOffset: CGFloat = 0 + + enum Tab: Hashable { + case home, library, browse, search, profile + } + + var body: some View { + ZStack(alignment: .bottom) { + TabView(selection: $selectedTab) { + HomeView() + .tabItem { Label("Home", systemImage: "house.fill") } + .tag(Tab.home) + + LibraryView() + .tabItem { Label("Library", systemImage: "book.pages.fill") } + .tag(Tab.library) + + BrowseView() + .tabItem { Label("Discover", systemImage: "sparkles") } + .tag(Tab.browse) + + SearchView() + .tabItem { Label("Search", systemImage: "magnifyingglass") } + .tag(Tab.search) + + ProfileView() + .tabItem { Label("Profile", systemImage: "person.fill") } + .tag(Tab.profile) + } + + // Mini player bar — sits above the tab bar + if audioPlayer.isActive && !showFullPlayer && !readerIsActive { + MiniPlayerBar(showFullPlayer: $showFullPlayer) + .padding(.bottom, 49) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive) + } + + // Full player — slides up from the bottom + if showFullPlayer { + FullPlayerView(onDismiss: { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + showFullPlayer = false + fullPlayerDragOffset = 0 + } + }) + .offset(y: max(fullPlayerDragOffset, 0)) + .gesture( + DragGesture(minimumDistance: 10) + .onChanged { value in + if value.translation.height > 0 { + fullPlayerDragOffset = value.translation.height + } + } + .onEnded { value in + let velocity = value.predictedEndTranslation.height - value.translation.height + if value.translation.height > 120 || velocity > 400 { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + showFullPlayer = false + fullPlayerDragOffset = 0 + } + } else { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + fullPlayerDragOffset = 0 + } + } + } + ) + .transition(.move(edge: .bottom)) + .animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer) + .ignoresSafeArea() + } + } + .animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer) + .onPreferenceChange(HideMiniPlayerKey.self) { hide in + readerIsActive = hide + } + } +} diff --git a/ios/LibNovelV2/Extensions/NavDestination.swift b/ios/LibNovelV2/Extensions/NavDestination.swift new file mode 100644 index 0000000..d4dda25 --- /dev/null +++ b/ios/LibNovelV2/Extensions/NavDestination.swift @@ -0,0 +1,138 @@ +import SwiftUI + +// MARK: - Navigation destination enum + +enum NavDestination: Hashable { + case book(String) // slug + case chapter(String, Int) // slug + chapter number + case userProfile(String) // username + case browseCategory(sort: String, genre: String, status: String, title: String) +} + +// MARK: - View helpers + +extension View { + /// Registers app-wide navigationDestination for NavDestination values. + /// Apply once per NavigationStack. + func appNavigationDestination() -> some View { + modifier(AppNavigationDestinationModifier()) + } + + /// Standard "Error" alert driven by an optional String binding. + /// Suppresses network errors silently when offline (banner handles them). + func errorAlert(_ error: Binding) -> some View { + modifier(ErrorAlertModifier(error: error)) + } + + /// Signal to the root overlay that the mini player should be hidden. + func hideMiniPlayer() -> some View { + preference(key: HideMiniPlayerKey.self, value: true) + } + + /// Marks a cover image as the zoom source for a book navigation transition (iOS 18+). + func bookCoverZoomSource(slug: String) -> some View { + modifier(BookCoverZoomSource(slug: slug)) + } +} + +// MARK: - Error alert modifier + +private struct ErrorAlertModifier: ViewModifier { + @Binding var error: String? + @EnvironmentObject var networkMonitor: NetworkMonitor + + private var shouldShowAlert: Bool { + guard let msg = error else { return false } + if !networkMonitor.isConnected { + let keywords = ["internet", "offline", "network", "connection", "unreachable", "timed out", "no data"] + if keywords.contains(where: { msg.lowercased().contains($0) }) { + DispatchQueue.main.async { self.error = nil } + return false + } + } + return true + } + + func body(content: Content) -> some View { + content.alert("Error", isPresented: Binding( + get: { shouldShowAlert }, + set: { if !$0 { error = nil } } + )) { + Button("OK") { error = nil } + } message: { + Text(error ?? "") + } + } +} + +// MARK: - Navigation destination modifier + +private struct AppNavigationDestinationModifier: ViewModifier { + @Namespace private var zoomNamespace + + func body(content: Content) -> some View { + if #available(iOS 18.0, *) { + content + .navigationDestination(for: NavDestination.self) { dest in + switch dest { + case .book(let slug): + BookDetailView(slug: slug) + .navigationTransition(.zoom(sourceID: slug, in: zoomNamespace)) + case .chapter(let slug, let n): + ChapterReaderView(slug: slug, chapterNumber: n) + case .userProfile(let username): + UserProfileView(username: username) + case .browseCategory(let sort, let genre, let status, let title): + BrowseCategoryView(sort: sort, genre: genre, status: status, title: title) + } + } + .environment(\.bookZoomNamespace, zoomNamespace) + } else { + content + .navigationDestination(for: NavDestination.self) { dest in + switch dest { + case .book(let slug): BookDetailView(slug: slug) + case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n) + case .userProfile(let username): UserProfileView(username: username) + case .browseCategory(let sort, let genre, let status, let title): + BrowseCategoryView(sort: sort, genre: genre, status: status, title: title) + } + } + } + } +} + +// MARK: - Environment key: zoom namespace + +struct BookZoomNamespaceKey: EnvironmentKey { + static var defaultValue: Namespace.ID? { nil } +} + +extension EnvironmentValues { + var bookZoomNamespace: Namespace.ID? { + get { self[BookZoomNamespaceKey.self] } + set { self[BookZoomNamespaceKey.self] = newValue } + } +} + +// MARK: - Preference key: hide mini player + +struct HideMiniPlayerKey: PreferenceKey { + static var defaultValue = false + static func reduce(value: inout Bool, nextValue: () -> Bool) { value = value || nextValue() } +} + +// MARK: - Cover zoom source modifier + +struct BookCoverZoomSource: ViewModifier { + let slug: String + @Environment(\.bookZoomNamespace) private var namespace + + func body(content: Content) -> some View { + if #available(iOS 18.0, *), let ns = namespace { + content.matchedTransitionSource(id: slug, in: ns) + } else { + content + } + } +} diff --git a/ios/LibNovelV2/Extensions/String+App.swift b/ios/LibNovelV2/Extensions/String+App.swift new file mode 100644 index 0000000..e96307f --- /dev/null +++ b/ios/LibNovelV2/Extensions/String+App.swift @@ -0,0 +1,19 @@ +import Foundation + +extension String { + /// Strips trailing date parentheticals from chapter titles. + /// Handles formats like: + /// " (January 5, 2025)" + /// " - Jan 01 2024" + func strippingTrailingDate() -> String { + let patterns = [ + #"\s*\([A-Za-z]+ \d{1,2},\s+\d{4}\)\s*$"#, + #"\s*[-–]\s*\w+\s+\d{1,2}\s+\d{4}\s*$"#, + ] + var result = self + for pattern in patterns { + result = result.replacingOccurrences(of: pattern, with: "", options: .regularExpression) + } + return result.trimmingCharacters(in: .whitespaces) + } +} diff --git a/ios/LibNovelV2/LibNovelV2.xcodeproj/project.pbxproj b/ios/LibNovelV2/LibNovelV2.xcodeproj/project.pbxproj new file mode 100644 index 0000000..227599a --- /dev/null +++ b/ios/LibNovelV2/LibNovelV2.xcodeproj/project.pbxproj @@ -0,0 +1,577 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 075C7E597E108D806195B2F0 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A6F099EE054F6EF867B19D9 /* HomeViewModel.swift */; }; + 280AC764BC30130EDB27A3F0 /* AudioDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72BA2BF82A660E953CBB526A /* AudioDownloadService.swift */; }; + 29D0FB039902E6691FBE40DA /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC1125FE0F6CD9F01F69B75 /* SearchViewModel.swift */; }; + 2FB2A044EBE6B90CFB51CF58 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2634D20198A966396121230 /* LibraryView.swift */; }; + 30EE28A725E2FA69F8FFCEF8 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E61714857FDAA22186D7A6C /* BookDetailViewModel.swift */; }; + 43034688B18F6F6CD65C5DE5 /* BrowseCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE69B91683576A056DE99EC /* BrowseCategoryView.swift */; }; + 464782001051686356AF728B /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736DA6CB7D7759E1791F6236 /* SearchView.swift */; }; + 4F72B63F12BB364C561B5B69 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378336A1684E738283821857 /* ContentView.swift */; }; + 5FCFCBFBEEFDFD2081068317 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4A3006B972DFF660959FE3 /* APIClient.swift */; }; + 6340BF19FE12FCEBE9607889 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D37A1BCABF9787BA6E243C8F /* ProfileView.swift */; }; + 64B17B6E30F44E87F33B886B /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4753E3FCFD2C6AEB0E58D5A1 /* ChapterReaderViewModel.swift */; }; + 7431E92F141CFFF28E891A11 /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C68D19B123EC191D53A694E /* BookDetailView.swift */; }; + 78F2392702ACB553CAFDB335 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D006B236F6FE653131FFD2 /* PlayerViews.swift */; }; + 792042C137942BCF8CB99C4F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125054A25A37A42295D49B10 /* NetworkMonitor.swift */; }; + 7C59289066AFD8A999DB9A0A /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF859D4970913FEBA89CB0F /* CommonViews.swift */; }; + 9F4A645472DC48AD32D5EDCD /* ChapterReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F48756100041DE38F573449 /* ChapterReaderView.swift */; }; + 9FD80E1B54ED74F430064904 /* LibNovelV2App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D88622224F38A541CE9F8D /* LibNovelV2App.swift */; }; + A753C2AE73CAA00BF1AB0EA4 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 880A0B86A80386BEA76FF388 /* NavDestination.swift */; }; + B1E2F3A4C5D6E7F8A9B0C1D2 /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D3E4F5A6B7C8D9E0F1A2B3 /* String+App.swift */; }; + ABB16424CEED3C5E9AAC08B2 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C95D52A96318B6CAD22EB0 /* BrowseView.swift */; }; + ACCA21E0EDF8BED26E193A76 /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92BE9AB59740382D85BD5296 /* DownloadsView.swift */; }; + ACE6D62D8E547A90380FB689 /* BookVoicePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E76C0661FC6FAB3BAA86711 /* BookVoicePreferences.swift */; }; + B4C6205A3A7A7A29EDA691FF /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C5E37231B0150A128C72D49 /* HomeView.swift */; }; + B8C5C43F299C89CFAE4000F1 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC088495FFC3053AAE0F124 /* RootTabView.swift */; }; + BEE8DF9B5E6C35389FB07951 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D715E3B2A6FE40FB628ADD2D /* AuthView.swift */; }; + C0EA8DBE751CB22F058CBF20 /* VoiceSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB713100B2B1F429924A107C /* VoiceSelectionView.swift */; }; + DDBAD183F7974A6FDAECB93C /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B3C942AFBA555D43F56C53 /* LibraryViewModel.swift */; }; + E64BCBBA92A983C3851754B5 /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 930C6A69F3E601E2297071CD /* AudioPlayerService.swift */; }; + E8112B785D129C26FEC054AB /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1548C08BADD28B057A9DFD5F /* UserProfileView.swift */; }; + F1DB9BC6DC6DFEEA010B7CDF /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F98B6C380A20E783F1F7A7DB /* AuthStore.swift */; }; + F4DAA587A097C597A9841563 /* BrowseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B8078366569A958BE54D23 /* BrowseViewModel.swift */; }; + FC954C552CC0BDFB619BF207 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4180EB2AEECC51E4A7F5231 /* Models.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 06C95D52A96318B6CAD22EB0 /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = ""; }; + 125054A25A37A42295D49B10 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; + 1548C08BADD28B057A9DFD5F /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; + 2E76C0661FC6FAB3BAA86711 /* BookVoicePreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVoicePreferences.swift; sourceTree = ""; }; + 378336A1684E738283821857 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 3C5E37231B0150A128C72D49 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + 3EF859D4970913FEBA89CB0F /* CommonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonViews.swift; sourceTree = ""; }; + 4753E3FCFD2C6AEB0E58D5A1 /* ChapterReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderViewModel.swift; sourceTree = ""; }; + 4A6F099EE054F6EF867B19D9 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; + 5C68D19B123EC191D53A694E /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = ""; }; + 71D006B236F6FE653131FFD2 /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = ""; }; + 72BA2BF82A660E953CBB526A /* AudioDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDownloadService.swift; sourceTree = ""; }; + 736DA6CB7D7759E1791F6236 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; + 7E61714857FDAA22186D7A6C /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = ""; }; + 7F4A3006B972DFF660959FE3 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; + 84D88622224F38A541CE9F8D /* LibNovelV2App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelV2App.swift; sourceTree = ""; }; + 880A0B86A80386BEA76FF388 /* NavDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavDestination.swift; sourceTree = ""; }; + C2D3E4F5A6B7C8D9E0F1A2B3 /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = ""; }; + 8F48756100041DE38F573449 /* ChapterReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderView.swift; sourceTree = ""; }; + 92BE9AB59740382D85BD5296 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = ""; }; + 930C6A69F3E601E2297071CD /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = ""; }; + 94CB555099A941E16AD0531A /* LibNovelV2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovelV2.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 96B3C942AFBA555D43F56C53 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = ""; }; + ABE69B91683576A056DE99EC /* BrowseCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseCategoryView.swift; sourceTree = ""; }; + B4180EB2AEECC51E4A7F5231 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + BFC088495FFC3053AAE0F124 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = ""; }; + D37A1BCABF9787BA6E243C8F /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; + D715E3B2A6FE40FB628ADD2D /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; + DB713100B2B1F429924A107C /* VoiceSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionView.swift; sourceTree = ""; }; + F2634D20198A966396121230 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; + F2B8078366569A958BE54D23 /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = ""; }; + F98B6C380A20E783F1F7A7DB /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = ""; }; + FCC1125FE0F6CD9F01F69B75 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 03533E32FF0C2EAF1915AD15 /* Products */ = { + isa = PBXGroup; + children = ( + 94CB555099A941E16AD0531A /* LibNovelV2.app */, + ); + name = Products; + sourceTree = ""; + }; + 19F98554C19DCB1FD6ED835E /* Services */ = { + isa = PBXGroup; + children = ( + 72BA2BF82A660E953CBB526A /* AudioDownloadService.swift */, + 930C6A69F3E601E2297071CD /* AudioPlayerService.swift */, + F98B6C380A20E783F1F7A7DB /* AuthStore.swift */, + 2E76C0661FC6FAB3BAA86711 /* BookVoicePreferences.swift */, + 125054A25A37A42295D49B10 /* NetworkMonitor.swift */, + ); + path = Services; + sourceTree = ""; + }; + 20E9B4B0C0EDDB3313149544 /* Common */ = { + isa = PBXGroup; + children = ( + 3EF859D4970913FEBA89CB0F /* CommonViews.swift */, + ); + path = Common; + sourceTree = ""; + }; + 25D179F65B0041EE826DEF5B /* App */ = { + isa = PBXGroup; + children = ( + 378336A1684E738283821857 /* ContentView.swift */, + 84D88622224F38A541CE9F8D /* LibNovelV2App.swift */, + BFC088495FFC3053AAE0F124 /* RootTabView.swift */, + ); + path = App; + sourceTree = ""; + }; + 2F4B97A2A2234F71AE2C46B2 /* Home */ = { + isa = PBXGroup; + children = ( + 3C5E37231B0150A128C72D49 /* HomeView.swift */, + ); + path = Home; + sourceTree = ""; + }; + 36240FA179A3701F15D1AAE1 /* Extensions */ = { + isa = PBXGroup; + children = ( + 880A0B86A80386BEA76FF388 /* NavDestination.swift */, + C2D3E4F5A6B7C8D9E0F1A2B3 /* String+App.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 3A6125CA86E249F3D6DC7F8C /* BookDetail */ = { + isa = PBXGroup; + children = ( + 5C68D19B123EC191D53A694E /* BookDetailView.swift */, + ); + path = BookDetail; + sourceTree = ""; + }; + 448620B67D4AEEEF2CAED3C0 /* Models */ = { + isa = PBXGroup; + children = ( + B4180EB2AEECC51E4A7F5231 /* Models.swift */, + ); + path = Models; + sourceTree = ""; + }; + 716D22431B17611F7A418D9F /* Profile */ = { + isa = PBXGroup; + children = ( + D37A1BCABF9787BA6E243C8F /* ProfileView.swift */, + 1548C08BADD28B057A9DFD5F /* UserProfileView.swift */, + DB713100B2B1F429924A107C /* VoiceSelectionView.swift */, + ); + path = Profile; + sourceTree = ""; + }; + 8BCE05349B706BF8EE0E16DD /* LibNovelV2 */ = { + isa = PBXGroup; + children = ( + ); + name = LibNovelV2; + path = .; + sourceTree = ""; + }; + 9AFE0816FF2E9D8DBBA470BD /* Downloads */ = { + isa = PBXGroup; + children = ( + 92BE9AB59740382D85BD5296 /* DownloadsView.swift */, + ); + path = Downloads; + sourceTree = ""; + }; + 9CFE23EEA1B9E264A36D0FC4 /* Search */ = { + isa = PBXGroup; + children = ( + 736DA6CB7D7759E1791F6236 /* SearchView.swift */, + ); + path = Search; + sourceTree = ""; + }; + 9E5A2471B9D5ECAF6B65FD22 /* ViewModels */ = { + isa = PBXGroup; + children = ( + 7E61714857FDAA22186D7A6C /* BookDetailViewModel.swift */, + F2B8078366569A958BE54D23 /* BrowseViewModel.swift */, + 4753E3FCFD2C6AEB0E58D5A1 /* ChapterReaderViewModel.swift */, + 4A6F099EE054F6EF867B19D9 /* HomeViewModel.swift */, + 96B3C942AFBA555D43F56C53 /* LibraryViewModel.swift */, + FCC1125FE0F6CD9F01F69B75 /* SearchViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + A05A1FE213A8E179B2302EF2 /* Auth */ = { + isa = PBXGroup; + children = ( + D715E3B2A6FE40FB628ADD2D /* AuthView.swift */, + ); + path = Auth; + sourceTree = ""; + }; + AA1F8D9C3DA40A1ADCF2B432 = { + isa = PBXGroup; + children = ( + 25D179F65B0041EE826DEF5B /* App */, + 36240FA179A3701F15D1AAE1 /* Extensions */, + 8BCE05349B706BF8EE0E16DD /* LibNovelV2 */, + 448620B67D4AEEEF2CAED3C0 /* Models */, + AFDC950B142FEDA471F394EC /* Networking */, + C468271A8BC443D1B82A1BE0 /* Resources */, + 19F98554C19DCB1FD6ED835E /* Services */, + 9E5A2471B9D5ECAF6B65FD22 /* ViewModels */, + CBC1A32FA53E9B3D5E15995D /* Views */, + 03533E32FF0C2EAF1915AD15 /* Products */, + ); + indentWidth = 4; + sourceTree = ""; + tabWidth = 4; + usesTabs = 0; + }; + AF1FE530FDE94947D4966251 /* ChapterReader */ = { + isa = PBXGroup; + children = ( + 8F48756100041DE38F573449 /* ChapterReaderView.swift */, + ); + path = ChapterReader; + sourceTree = ""; + }; + AFDC950B142FEDA471F394EC /* Networking */ = { + isa = PBXGroup; + children = ( + 7F4A3006B972DFF660959FE3 /* APIClient.swift */, + ); + path = Networking; + sourceTree = ""; + }; + BFA030D1CE2D312C539318DA /* Browse */ = { + isa = PBXGroup; + children = ( + ABE69B91683576A056DE99EC /* BrowseCategoryView.swift */, + 06C95D52A96318B6CAD22EB0 /* BrowseView.swift */, + ); + path = Browse; + sourceTree = ""; + }; + C468271A8BC443D1B82A1BE0 /* Resources */ = { + isa = PBXGroup; + children = ( + ); + path = Resources; + sourceTree = ""; + }; + CBC1A32FA53E9B3D5E15995D /* Views */ = { + isa = PBXGroup; + children = ( + A05A1FE213A8E179B2302EF2 /* Auth */, + 3A6125CA86E249F3D6DC7F8C /* BookDetail */, + BFA030D1CE2D312C539318DA /* Browse */, + AF1FE530FDE94947D4966251 /* ChapterReader */, + 20E9B4B0C0EDDB3313149544 /* Common */, + 9AFE0816FF2E9D8DBBA470BD /* Downloads */, + 2F4B97A2A2234F71AE2C46B2 /* Home */, + ED5843EA1B9CB1AD97664571 /* Library */, + F9025CCFC608DCEB21B4D9F5 /* Player */, + 716D22431B17611F7A418D9F /* Profile */, + 9CFE23EEA1B9E264A36D0FC4 /* Search */, + ); + path = Views; + sourceTree = ""; + }; + ED5843EA1B9CB1AD97664571 /* Library */ = { + isa = PBXGroup; + children = ( + F2634D20198A966396121230 /* LibraryView.swift */, + ); + path = Library; + sourceTree = ""; + }; + F9025CCFC608DCEB21B4D9F5 /* Player */ = { + isa = PBXGroup; + children = ( + 71D006B236F6FE653131FFD2 /* PlayerViews.swift */, + ); + path = Player; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7EEA688C50B734EA22C04CF1 /* LibNovelV2 */ = { + isa = PBXNativeTarget; + buildConfigurationList = 38B2D5E78E086CB61602C375 /* Build configuration list for PBXNativeTarget "LibNovelV2" */; + buildPhases = ( + BE6BEAD873B53447AABD2346 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = LibNovelV2; + packageProductDependencies = ( + ); + productName = LibNovelV2; + productReference = 94CB555099A941E16AD0531A /* LibNovelV2.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 1AC8476B8E9026EB9CE2B4FF /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1600; + }; + buildConfigurationList = 92AD4EEF6E109D5DC11B2A6F /* Build configuration list for PBXProject "LibNovelV2" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = AA1F8D9C3DA40A1ADCF2B432; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 03533E32FF0C2EAF1915AD15 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7EEA688C50B734EA22C04CF1 /* LibNovelV2 */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + BE6BEAD873B53447AABD2346 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5FCFCBFBEEFDFD2081068317 /* APIClient.swift in Sources */, + 280AC764BC30130EDB27A3F0 /* AudioDownloadService.swift in Sources */, + E64BCBBA92A983C3851754B5 /* AudioPlayerService.swift in Sources */, + F1DB9BC6DC6DFEEA010B7CDF /* AuthStore.swift in Sources */, + BEE8DF9B5E6C35389FB07951 /* AuthView.swift in Sources */, + 7431E92F141CFFF28E891A11 /* BookDetailView.swift in Sources */, + 30EE28A725E2FA69F8FFCEF8 /* BookDetailViewModel.swift in Sources */, + ACE6D62D8E547A90380FB689 /* BookVoicePreferences.swift in Sources */, + 43034688B18F6F6CD65C5DE5 /* BrowseCategoryView.swift in Sources */, + ABB16424CEED3C5E9AAC08B2 /* BrowseView.swift in Sources */, + F4DAA587A097C597A9841563 /* BrowseViewModel.swift in Sources */, + 9F4A645472DC48AD32D5EDCD /* ChapterReaderView.swift in Sources */, + 64B17B6E30F44E87F33B886B /* ChapterReaderViewModel.swift in Sources */, + 7C59289066AFD8A999DB9A0A /* CommonViews.swift in Sources */, + 4F72B63F12BB364C561B5B69 /* ContentView.swift in Sources */, + ACCA21E0EDF8BED26E193A76 /* DownloadsView.swift in Sources */, + B4C6205A3A7A7A29EDA691FF /* HomeView.swift in Sources */, + 075C7E597E108D806195B2F0 /* HomeViewModel.swift in Sources */, + 9FD80E1B54ED74F430064904 /* LibNovelV2App.swift in Sources */, + 2FB2A044EBE6B90CFB51CF58 /* LibraryView.swift in Sources */, + DDBAD183F7974A6FDAECB93C /* LibraryViewModel.swift in Sources */, + FC954C552CC0BDFB619BF207 /* Models.swift in Sources */, + A753C2AE73CAA00BF1AB0EA4 /* NavDestination.swift in Sources */, + B1E2F3A4C5D6E7F8A9B0C1D2 /* String+App.swift in Sources */, + 792042C137942BCF8CB99C4F /* NetworkMonitor.swift in Sources */, + 78F2392702ACB553CAFDB335 /* PlayerViews.swift in Sources */, + 6340BF19FE12FCEBE9607889 /* ProfileView.swift in Sources */, + B8C5C43F299C89CFAE4000F1 /* RootTabView.swift in Sources */, + 464782001051686356AF728B /* SearchView.swift in Sources */, + 29D0FB039902E6691FBE40DA /* SearchViewModel.swift in Sources */, + E8112B785D129C26FEC054AB /* UserProfileView.swift in Sources */, + C0EA8DBE751CB22F058CBF20 /* VoiceSelectionView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 019B1386650D49B9F4F6CCF7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc"; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.10; + }; + name = Release; + }; + 086D97837CBA0A9177D50BB2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Distribution"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = GHZXC6FVMU; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovelV2; + PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 1A953D152E39A2F172BB4DE4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = GHZXC6FVMU; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovelV2; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + C91972DB753AE2CF04BED70E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc"; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.10; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 38B2D5E78E086CB61602C375 /* Build configuration list for PBXNativeTarget "LibNovelV2" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A953D152E39A2F172BB4DE4 /* Debug */, + 086D97837CBA0A9177D50BB2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 92AD4EEF6E109D5DC11B2A6F /* Build configuration list for PBXProject "LibNovelV2" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C91972DB753AE2CF04BED70E /* Debug */, + 019B1386650D49B9F4F6CCF7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 1AC8476B8E9026EB9CE2B4FF /* Project object */; +} diff --git a/ios/LibNovelV2/LibNovelV2.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/LibNovelV2/LibNovelV2.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/LibNovelV2/LibNovelV2.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/LibNovelV2/LibNovelV2.xcodeproj/xcshareddata/xcschemes/LibNovelV2.xcscheme b/ios/LibNovelV2/LibNovelV2.xcodeproj/xcshareddata/xcschemes/LibNovelV2.xcscheme new file mode 100644 index 0000000..3a7f4a9 --- /dev/null +++ b/ios/LibNovelV2/LibNovelV2.xcodeproj/xcshareddata/xcschemes/LibNovelV2.xcscheme @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/LibNovelV2/Models/Models.swift b/ios/LibNovelV2/Models/Models.swift new file mode 100644 index 0000000..24f8055 --- /dev/null +++ b/ios/LibNovelV2/Models/Models.swift @@ -0,0 +1,417 @@ +import Foundation +import SwiftUI + +// MARK: - Book + +struct Book: Identifiable, Codable, Hashable { + let id: String + let slug: String + let title: String + let author: String + let cover: String // proxied via /api/cover/... + let status: String + let genres: [String] + let summary: String + let totalChapters: Int + let sourceURL: String + let ranking: Int + let metaUpdated: String + + enum CodingKeys: String, CodingKey { + case id, slug, title, author, cover, status, genres, summary, ranking + case totalChapters = "total_chapters" + case sourceURL = "source_url" + case metaUpdated = "meta_updated" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + slug = try c.decode(String.self, forKey: .slug) + title = try c.decode(String.self, forKey: .title) + author = try c.decodeIfPresent(String.self, forKey: .author) ?? "" + cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? "" + status = try c.decodeIfPresent(String.self, forKey: .status) ?? "" + totalChapters = try c.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 0 + sourceURL = try c.decodeIfPresent(String.self, forKey: .sourceURL) ?? "" + ranking = try c.decodeIfPresent(Int.self, forKey: .ranking) ?? 0 + metaUpdated = try c.decodeIfPresent(String.self, forKey: .metaUpdated) ?? "" + summary = try c.decodeIfPresent(String.self, forKey: .summary) ?? "" + + // genres can arrive as a JSON-encoded string or a real array + if let arr = try? c.decode([String].self, forKey: .genres) { + genres = arr + } else if let raw = try? c.decode(String.self, forKey: .genres), + let data = raw.data(using: .utf8), + let arr = try? JSONDecoder().decode([String].self, from: data) { + genres = arr + } else { + genres = [] + } + } +} + +// MARK: - Chapter index + +struct ChapterIndex: Identifiable, Codable, Hashable { + let id: String + let slug: String + let number: Int + let title: String + let dateLabel: String + + enum CodingKeys: String, CodingKey { + case id, slug, number, title + case dateLabel = "date_label" + } +} + +struct ChapterBrief: Identifiable, Codable, Hashable { + var id: Int { number } + let number: Int + let title: String +} + +// Full chapter response from /api/chapter-text/{slug}/{n} +struct ChapterResponse: Decodable { + struct BookBrief: Decodable { + let slug: String + let title: String + let cover: String + } + struct ChapterDetail: Decodable { + let number: Int + let title: String + let dateLabel: String + + enum CodingKeys: String, CodingKey { + case number, title + case dateLabel = "date_label" + } + } + + let book: BookBrief + let chapter: ChapterDetail + let chapters: [ChapterBrief] + let html: String + let text: String + let prev: Int? + let next: Int? +} + +// MARK: - Ranking + +struct RankingItem: Codable, Identifiable { + var id: String { slug } + let rank: Int + let slug: String + let title: String + let author: String + let cover: String + let status: String + let genres: [String] + let sourceURL: String + + enum CodingKeys: String, CodingKey { + case rank, slug, title, author, cover, status, genres + case sourceURL = "source_url" + } +} + +// MARK: - Browse listing + +struct NovelListing: Codable, Identifiable { + var id: String { slug } + let slug: String + let title: String + let author: String? + let cover: String? + let status: String? + let genres: [String]? + let rank: Int? + let rating: String? + let chapters: String? // e.g. "123 chapters" + let url: String? + let sourceURL: String? + + enum CodingKeys: String, CodingKey { + case slug, title, author, cover, status, genres, rank, rating, chapters, url + case sourceURL = "source_url" + } +} + +// MARK: - Home + +struct HomeStats: Codable { + let totalBooks: Int + let totalChapters: Int + let booksInProgress: Int + + enum CodingKeys: String, CodingKey { + case totalBooks = "total_books" + case totalChapters = "total_chapters" + case booksInProgress = "books_in_progress" + } +} + +struct ContinueReadingItem: Identifiable { + var id: String { book.id } + let book: Book + let chapter: Int +} + +struct SubscriptionFeedItem: Identifiable, Decodable { + var id: String { book.id + readerUsername } + let book: Book + let readerUsername: String + + enum CodingKeys: String, CodingKey { + case book + case readerUsername = "readerUsername" + } +} + +// MARK: - User + +struct AppUser: Codable, Identifiable { + let id: String + let username: String + let role: String + let created: String + let avatarURL: String? + + var isAdmin: Bool { role == "admin" } + + enum CodingKeys: String, CodingKey { + case id, username, role, created + case avatarURL = "avatar_url" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + username = try c.decode(String.self, forKey: .username) + role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user" + created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" + avatarURL = try c.decodeIfPresent(String.self, forKey: .avatarURL) + } + + init(id: String, username: String, role: String, created: String, avatarURL: String?) { + self.id = id + self.username = username + self.role = role + self.created = created + self.avatarURL = avatarURL + } +} + +// MARK: - User settings + +struct UserSettings: Codable { + var autoNext: Bool + var voice: String + var speed: Double + + static let `default` = UserSettings(autoNext: false, voice: "af_bella", speed: 1.0) +} + +// MARK: - Session + +struct UserSession: Codable, Identifiable { + let id: String + let userAgent: String + let ip: String + let createdAt: String + let lastSeen: String + var isCurrent: Bool + + enum CodingKeys: String, CodingKey { + case id, ip + case userAgent = "user_agent" + case createdAt = "created_at" + case lastSeen = "last_seen" + case isCurrent = "is_current" + } +} + +// MARK: - Comments + +struct BookComment: Identifiable, Codable, Hashable { + let id: String + let slug: String + let userId: String + let username: String + let body: String + var upvotes: Int + var downvotes: Int + let created: String + let parentId: String + var replies: [BookComment]? + + enum CodingKeys: String, CodingKey { + case id, slug, username, body, upvotes, downvotes, created, replies + case userId = "user_id" + case parentId = "parent_id" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? "" + userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? "" + username = try c.decodeIfPresent(String.self, forKey: .username) ?? "" + body = try c.decodeIfPresent(String.self, forKey: .body) ?? "" + upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0 + downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0 + created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" + parentId = try c.decodeIfPresent(String.self, forKey: .parentId) ?? "" + replies = try c.decodeIfPresent([BookComment].self, forKey: .replies) + } +} + +struct CommentsResponse: Decodable { + let comments: [BookComment] + let myVotes: [String: String] + let avatarUrls: [String: String] + + enum CodingKeys: String, CodingKey { + case comments, myVotes, avatarUrls + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + comments = try c.decode([BookComment].self, forKey: .comments) + myVotes = try c.decodeIfPresent([String: String].self, forKey: .myVotes) ?? [:] + avatarUrls = try c.decodeIfPresent([String: String].self, forKey: .avatarUrls) ?? [:] + } +} + +// MARK: - Public user profile + +struct PublicUserProfile: Decodable, Identifiable { + let id: String + let username: String + let avatarUrl: String? + let created: String + let followerCount: Int + let followingCount: Int + let isSubscribed: Bool + let isSelf: Bool + + enum CodingKeys: String, CodingKey { + case id, username, created + case avatarUrl = "avatarUrl" + case followerCount = "followerCount" + case followingCount = "followingCount" + case isSubscribed = "isSubscribed" + case isSelf = "isSelf" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + username = try c.decode(String.self, forKey: .username) + avatarUrl = try c.decodeIfPresent(String.self, forKey: .avatarUrl) + created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" + followerCount = try c.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0 + followingCount = try c.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0 + isSubscribed = try c.decodeIfPresent(Bool.self, forKey: .isSubscribed) ?? false + isSelf = try c.decodeIfPresent(Bool.self, forKey: .isSelf) ?? false + } +} + +struct PublicLibraryItem: Decodable, Identifiable { + var id: String { book.id } + let book: Book + let lastChapter: Int? + let saved: Bool + + enum CodingKeys: String, CodingKey { + case book + case lastChapter = "last_chapter" + case saved + } +} + +struct PublicUserLibraryResponse: Decodable { + let currentlyReading: [PublicLibraryItem] + let library: [PublicLibraryItem] + + enum CodingKeys: String, CodingKey { + case currentlyReading = "currently_reading" + case library + } +} + +// MARK: - Reader Settings (local — UserDefaults) + +enum ReaderTheme: String, CaseIterable, Codable { + case white, sepia, night + + var backgroundColor: Color { + switch self { + case .white: return Color(.sRGB, white: 1.0, opacity: 1) + case .sepia: return Color(red: 0.97, green: 0.93, blue: 0.82) + case .night: return Color(red: 0.10, green: 0.10, blue: 0.12) + } + } + + var textColor: Color { + switch self { + case .white: return Color(.sRGB, white: 0.10, opacity: 1) + case .sepia: return Color(red: 0.25, green: 0.18, blue: 0.08) + case .night: return Color(red: 0.85, green: 0.85, blue: 0.87) + } + } + + var colorScheme: ColorScheme? { + switch self { + case .white: return nil + case .sepia: return .light + case .night: return .dark + } + } +} + +enum ReaderFont: String, CaseIterable, Codable { + case system = "System" + case georgia = "Georgia" + case newYork = "New York" + + var fontName: String? { + switch self { + case .system: return nil + case .georgia: return "Georgia" + case .newYork: return "NewYorkMedium-Regular" + } + } +} + +struct ReaderSettings: Codable, Equatable { + var fontSize: CGFloat = 17 + var lineSpacing: CGFloat = 1.7 + var font: ReaderFont = .system + var theme: ReaderTheme = .white + var scrollMode: Bool = false + + private static let key = "v2.readerSettings" + + static func load() -> ReaderSettings { + guard let data = UserDefaults.standard.data(forKey: key), + let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data) + else { return ReaderSettings() } + return decoded + } + + func save() { + if let data = try? JSONEncoder().encode(self) { + UserDefaults.standard.set(data, forKey: ReaderSettings.key) + } + } +} + +// MARK: - Audio prefetch status + +enum NextPrefetchStatus { + case none, prefetching, prefetched, failed +} diff --git a/ios/LibNovelV2/Networking/APIClient.swift b/ios/LibNovelV2/Networking/APIClient.swift new file mode 100644 index 0000000..96f0196 --- /dev/null +++ b/ios/LibNovelV2/Networking/APIClient.swift @@ -0,0 +1,520 @@ +import Foundation + +// MARK: - API Client +// Communicates with the SvelteKit UI server (/api/* endpoints). +// Auth is carried via the libnovel_auth cookie (HMAC-signed token). + +actor APIClient { + static let shared = APIClient() + + var baseURL: URL + private var authCookie: String? // raw "libnovel_auth=" header value + + private let session: URLSession = { + let config = URLSessionConfiguration.default + config.httpCookieAcceptPolicy = .always + config.httpShouldSetCookies = true + config.httpCookieStorage = HTTPCookieStorage.shared + return URLSession(configuration: config) + }() + + private init() { + let urlString = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String + ?? "https://v2.libnovel.kalekber.cc" + baseURL = URL(string: urlString)! + } + + // MARK: - Auth cookie management + + func setAuthCookie(_ value: String?) { + authCookie = value + if let value { + let cookieProps: [HTTPCookiePropertyKey: Any] = [ + .name: "libnovel_auth", + .value: value, + .domain: baseURL.host ?? "localhost", + .path: "/" + ] + if let cookie = HTTPCookie(properties: cookieProps) { + HTTPCookieStorage.shared.setCookie(cookie) + } + } else { + let storage = HTTPCookieStorage.shared + storage.cookies(for: baseURL)?.forEach { storage.deleteCookie($0) } + } + } + + // MARK: - Low-level request builder + + private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest { + let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + + "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let url = URL(string: urlString) else { throw APIError.invalidResponse } + var req = URLRequest(url: url) + req.httpMethod = method + req.setValue("application/json", forHTTPHeaderField: "Accept") + if let body { + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONEncoder().encode(body) + } + return req + } + + // MARK: - Generic fetch + + func fetch(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> T { + let req = try makeRequest(path, method: method, body: body) + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + let rawBody = String(data: data, encoding: .utf8) ?? "" + guard (200..<300).contains(http.statusCode) else { + if http.statusCode == 401 { throw APIError.unauthorized } + throw APIError.httpError(http.statusCode, rawBody) + } + do { + return try JSONDecoder.apiDecoder.decode(T.self, from: data) + } catch { + throw APIError.decodingError(error) + } + } + + func fetchVoid(_ path: String, method: String = "GET", body: Encodable? = nil) async throws { + let req = try makeRequest(path, method: method, body: body) + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + guard (200..<300).contains(http.statusCode) else { + let rawBody = String(data: data, encoding: .utf8) ?? "" + throw APIError.httpError(http.statusCode, rawBody) + } + } + + // MARK: - Auth + + private struct LoginRequest: Encodable { + let username: String + let password: String + } + + struct LoginResponse: Decodable { + let token: String + let user: AppUser + } + + func login(username: String, password: String) async throws -> LoginResponse { + try await fetch("/api/auth/login", method: "POST", + body: LoginRequest(username: username, password: password)) + } + + func register(username: String, password: String) async throws -> LoginResponse { + try await fetch("/api/auth/register", method: "POST", + body: LoginRequest(username: username, password: password)) + } + + func logout() async throws { + let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST") + setAuthCookie(nil) + } + + // MARK: - Home + + func homeData() async throws -> HomeDataResponse { + try await fetch("/api/home") + } + + // MARK: - Library + + func library() async throws -> [LibraryItem] { + try await fetch("/api/library") + } + + func saveBook(slug: String) async throws { + let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "POST") + } + + func unsaveBook(slug: String) async throws { + let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "DELETE") + } + + // MARK: - Book Detail + + func bookDetail(slug: String) async throws -> BookDetailResponse { + try await fetch("/api/book/\(slug)") + } + + // MARK: - Chapter + + func chapterContent(slug: String, chapter: Int) async throws -> ChapterResponse { + try await fetch("/api/chapter/\(slug)/\(chapter)") + } + + // MARK: - Browse + + func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse { + let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)" + return try await fetch("/api/browse-page\(query)") + } + + func search(query: String) async throws -> SearchResponse { + let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query + return try await fetch("/api/search?q=\(encoded)") + } + + func ranking() async throws -> [RankingItem] { + try await fetch("/api/ranking") + } + + // MARK: - Progress + + func progress() async throws -> [ProgressEntry] { + try await fetch("/api/progress") + } + + func setProgress(slug: String, chapter: Int) async throws { + struct Body: Encodable { let chapter: Int } + let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter)) + } + + func deleteProgress(slug: String) async throws { + let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "DELETE") + } + + func audioTime(slug: String, chapter: Int) async throws -> Double? { + struct Response: Decodable { + let audioTime: Double? + enum CodingKeys: String, CodingKey { case audioTime = "audio_time" } + } + let r: Response = try await fetch("/api/progress/audio-time?slug=\(slug)&chapter=\(chapter)") + return r.audioTime + } + + func setAudioTime(slug: String, chapter: Int, time: Double) async throws { + struct Body: Encodable { + let slug: String; let chapter: Int; let audioTime: Double + enum CodingKeys: String, CodingKey { case slug, chapter; case audioTime = "audio_time" } + } + let _: EmptyResponse = try await fetch("/api/progress/audio-time", method: "PATCH", + body: Body(slug: slug, chapter: chapter, audioTime: time)) + } + + // MARK: - Audio + + func triggerAudio(slug: String, chapter: Int, voice: String, speed: Double) async throws -> AudioTriggerResponse { + struct Body: Encodable { let voice: String; let speed: Double } + return try await fetch("/api/audio/\(slug)/\(chapter)", method: "POST", body: Body(voice: voice, speed: speed)) + } + + /// Poll until the TTS job is done, failed, or the task is cancelled. + /// Returns the playback URL on success. + func pollAudioStatus(slug: String, chapter: Int, voice: String) async throws -> String { + let path = "/api/audio/status/\(slug)/\(chapter)?voice=\(voice)" + struct StatusResponse: Decodable { + let status: String + let url: String? + let error: String? + } + while true { + try Task.checkCancellation() + let r: StatusResponse = try await fetch(path) + switch r.status { + case "done": + guard let url = r.url, !url.isEmpty else { throw URLError(.badServerResponse) } + return url + case "failed": + throw NSError(domain: "AudioGeneration", code: 0, + userInfo: [NSLocalizedDescriptionKey: r.error ?? "Audio generation failed"]) + default: + try await Task.sleep(nanoseconds: 2_000_000_000) + } + } + } + + func presignAudio(slug: String, chapter: Int, voice: String) async throws -> String { + struct Response: Decodable { let url: String } + let r: Response = try await fetch("/api/presign/audio?slug=\(slug)&chapter=\(chapter)&voice=\(voice)") + return r.url + } + + func presignVoiceSample(voice: String) async throws -> String { + struct Response: Decodable { let url: String } + let r: Response = try await fetch("/api/presign/voice-sample?voice=\(voice)") + return r.url + } + + func voices() async throws -> [String] { + struct Response: Decodable { let voices: [String] } + let r: Response = try await fetch("/api/voices") + return r.voices + } + + // MARK: - Settings + + func settings() async throws -> UserSettings { + try await fetch("/api/settings") + } + + func updateSettings(_ settings: UserSettings) async throws { + let _: EmptyResponse = try await fetch("/api/settings", method: "PUT", body: settings) + } + + // MARK: - Sessions + + func sessions() async throws -> [UserSession] { + struct Response: Decodable { let sessions: [UserSession] } + let r: Response = try await fetch("/api/sessions") + return r.sessions + } + + func revokeSession(id: String) async throws { + let _: EmptyResponse = try await fetch("/api/sessions/\(id)", method: "DELETE") + } + + // MARK: - Avatar + + struct AvatarPresignResponse: Decodable { + let uploadURL: String + let key: String + enum CodingKeys: String, CodingKey { case uploadURL = "upload_url"; case key } + } + + struct AvatarResponse: Decodable { + let avatarURL: String? + enum CodingKeys: String, CodingKey { case avatarURL = "avatar_url" } + } + + func uploadAvatar(_ imageData: Data, mimeType: String = "image/jpeg") async throws -> String? { + let presign: AvatarPresignResponse = try await fetch( + "/api/profile/avatar", method: "POST", body: ["mime_type": mimeType]) + + guard let putURL = URL(string: presign.uploadURL) else { throw APIError.invalidResponse } + var putReq = URLRequest(url: putURL) + putReq.httpMethod = "PUT" + putReq.setValue(mimeType, forHTTPHeaderField: "Content-Type") + putReq.httpBody = imageData + let (_, putResp) = try await session.data(for: putReq) + guard let putHttp = putResp as? HTTPURLResponse, (200..<300).contains(putHttp.statusCode) else { + throw APIError.httpError((putResp as? HTTPURLResponse)?.statusCode ?? 0, "MinIO PUT failed") + } + + let result: AvatarResponse = try await fetch("/api/profile/avatar", method: "PATCH", body: ["key": presign.key]) + return result.avatarURL + } + + func fetchAvatarPresignedURL() async throws -> String? { + let result: AvatarResponse = try await fetch("/api/profile/avatar") + return result.avatarURL + } + + // MARK: - User Profiles & Subscriptions + + func fetchUserProfile(username: String) async throws -> PublicUserProfile { + try await fetch("/api/users/\(username)") + } + + @discardableResult + func subscribeUser(username: String) async throws -> Bool { + struct Response: Decodable { let subscribed: Bool } + let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "POST") + return r.subscribed + } + + @discardableResult + func unsubscribeUser(username: String) async throws -> Bool { + struct Response: Decodable { let subscribed: Bool } + let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "DELETE") + return r.subscribed + } + + func fetchUserLibrary(username: String) async throws -> PublicUserLibraryResponse { + try await fetch("/api/users/\(username)/library") + } + + // MARK: - Comments + + func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse { + try await fetch("/api/comments/\(slug)?sort=\(sort)") + } + + private struct PostCommentBody: Encodable { + let body: String + let parent_id: String? + } + + func postComment(slug: String, body: String, parentId: String? = nil) async throws -> BookComment { + try await fetch("/api/comments/\(slug)", method: "POST", + body: PostCommentBody(body: body, parent_id: parentId)) + } + + func voteComment(commentId: String, vote: String) async throws -> BookComment { + struct VoteBody: Encodable { let vote: String } + return try await fetch("/api/comment/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote)) + } + + func deleteComment(commentId: String) async throws { + try await fetchVoid("/api/comment/\(commentId)", method: "DELETE") + } +} + +// MARK: - Response types + +struct HomeDataResponse: Decodable { + struct ContinueItem: Decodable { + let book: Book + let chapter: Int + } + let continueReading: [ContinueItem] + let recentlyUpdated: [Book] + let stats: HomeStats + let subscriptionFeed: [SubscriptionFeedItem] + + enum CodingKeys: String, CodingKey { + case continueReading = "continue_reading" + case recentlyUpdated = "recently_updated" + case stats + case subscriptionFeed = "subscription_feed" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + continueReading = try c.decodeIfPresent([ContinueItem].self, forKey: .continueReading) ?? [] + recentlyUpdated = try c.decodeIfPresent([Book].self, forKey: .recentlyUpdated) ?? [] + stats = try c.decode(HomeStats.self, forKey: .stats) + subscriptionFeed = try c.decodeIfPresent([SubscriptionFeedItem].self, forKey: .subscriptionFeed) ?? [] + } +} + +struct LibraryItem: Decodable, Identifiable { + var id: String { book.id } + let book: Book + let savedAt: String + let lastChapter: Int? + + enum CodingKeys: String, CodingKey { + case book + case savedAt = "saved_at" + case lastChapter = "last_chapter" + } +} + +struct BookDetailResponse: Decodable { + let book: Book + let chapters: [ChapterIndex] + let inLib: Bool + let saved: Bool + let lastChapter: Int? + + enum CodingKeys: String, CodingKey { + case book, chapters + case inLib = "in_lib" + case saved + case lastChapter = "last_chapter" + } +} + +struct BrowseResponse: Decodable { + let novels: [BrowseNovel] + let page: Int + let hasNext: Bool +} + +struct BrowseNovel: Decodable, Identifiable, Hashable { + var id: String { slug.isEmpty ? url : slug } + let slug: String + let title: String + let cover: String + let rank: String + let rating: String + let chapters: String + let url: String + let author: String + let status: String + let genres: [String] + + enum CodingKeys: String, CodingKey { + case slug, title, cover, rank, rating, chapters, url, author, status, genres + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? "" + title = try c.decode(String.self, forKey: .title) + cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? "" + rank = try c.decodeIfPresent(String.self, forKey: .rank) ?? "" + rating = try c.decodeIfPresent(String.self, forKey: .rating) ?? "" + chapters = try c.decodeIfPresent(String.self, forKey: .chapters) ?? "" + url = try c.decodeIfPresent(String.self, forKey: .url) ?? "" + author = try c.decodeIfPresent(String.self, forKey: .author) ?? "" + status = try c.decodeIfPresent(String.self, forKey: .status) ?? "" + genres = try c.decodeIfPresent([String].self, forKey: .genres) ?? [] + } +} + +struct SearchResponse: Decodable { + let results: [BrowseNovel] + let localCount: Int + let remoteCount: Int + + enum CodingKeys: String, CodingKey { + case results + case localCount = "local_count" + case remoteCount = "remote_count" + } +} + +struct AudioTriggerResponse: Decodable { + let jobId: String? + let status: String? + let url: String? + let filename: String? + + enum CodingKeys: String, CodingKey { + case jobId = "job_id" + case status, url, filename + } + + var isAsync: Bool { jobId != nil } +} + +struct ProgressEntry: Decodable, Identifiable { + var id: String { slug } + let slug: String + let chapter: Int + let audioTime: Double? + let updated: String + + enum CodingKeys: String, CodingKey { + case slug, chapter, updated + case audioTime = "audio_time" + } +} + +struct EmptyResponse: Decodable {} + +// MARK: - API Error + +enum APIError: LocalizedError { + case invalidResponse + case httpError(Int, String) + case decodingError(Error) + case unauthorized + case networkError(Error) + + var errorDescription: String? { + switch self { + case .invalidResponse: return "Invalid server response" + case .httpError(let code, let m): return "HTTP \(code): \(m)" + case .decodingError(let e): return "Decode error: \(e.localizedDescription)" + case .unauthorized: return "Not authenticated" + case .networkError(let e): return e.localizedDescription + } + } +} + +// MARK: - JSONDecoder helper + +extension JSONDecoder { + static let apiDecoder: JSONDecoder = { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + }() +} diff --git a/ios/LibNovelV2/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/LibNovelV2/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..cea2357 --- /dev/null +++ b/ios/LibNovelV2/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,12 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { "alpha": "1.000", "blue": "0.043", "green": "0.620", "red": "0.961" } + }, + "idiom": "universal" + } + ], + "info": { "author": "xcode", "version": 1 } +} diff --git a/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..efca0a1 --- /dev/null +++ b/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,11 @@ +{ + "images": [ + { + "filename": "icon-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { "author": "xcode", "version": 1 } +} diff --git a/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000..820557a Binary files /dev/null and b/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/ios/LibNovelV2/Resources/Assets.xcassets/Contents.json b/ios/LibNovelV2/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..319a86b --- /dev/null +++ b/ios/LibNovelV2/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,3 @@ +{ + "info": { "author": "xcode", "version": 1 } +} diff --git a/ios/LibNovelV2/Resources/Info.plist b/ios/LibNovelV2/Resources/Info.plist new file mode 100644 index 0000000..43230b5 --- /dev/null +++ b/ios/LibNovelV2/Resources/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDisplayName + LibNovel + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + LibNovel + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LIBNOVEL_BASE_URL + $(LIBNOVEL_BASE_URL) + LSRequiresIPhoneOS + + UIBackgroundModes + + audio + fetch + processing + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/LibNovelV2/Services/AudioDownloadService.swift b/ios/LibNovelV2/Services/AudioDownloadService.swift new file mode 100644 index 0000000..8c7ac03 --- /dev/null +++ b/ios/LibNovelV2/Services/AudioDownloadService.swift @@ -0,0 +1,230 @@ +import Foundation +import Combine + +// MARK: - AudioDownloadService +// Manages offline TTS audio downloads with progress tracking. +// Uses a background URLSession so downloads survive app suspension. +// Keys use "::" separator (slugs contain hyphens). + +@MainActor +final class AudioDownloadService: NSObject, ObservableObject { + static let shared = AudioDownloadService() + + // MARK: - Published state + + @Published var downloads: [String: DownloadProgress] = [:] // key: "slug::chapter::voice" + @Published var downloadedChapters: Set = [] // key: "slug::chapter::voice" + + // MARK: - Private + + private var session: URLSession! + private var activeTasks: [String: URLSessionDownloadTask] = [:] + private let fileManager = FileManager.default + private let metadataKey = "v2.downloadedChapters" + + // MARK: - Init + + private override init() { + super.init() + let config = URLSessionConfiguration.background( + withIdentifier: "cc.kalekber.libnovel.v2.audio-downloads") + config.isDiscretionary = false + config.sessionSendsLaunchEvents = true + session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + loadMetadata() + } + + // MARK: - Public API + + func isDownloaded(slug: String, chapter: Int, voice: String) -> Bool { + downloadedChapters.contains(makeKey(slug: slug, chapter: chapter, voice: voice)) + } + + func localURL(slug: String, chapter: Int, voice: String) -> URL? { + guard isDownloaded(slug: slug, chapter: chapter, voice: voice) else { return nil } + return audioFileURL(slug: slug, chapter: chapter, voice: voice) + } + + func download(slug: String, chapter: Int, voice: String) async throws { + let key = makeKey(slug: slug, chapter: chapter, voice: voice) + guard !downloadedChapters.contains(key), activeTasks[key] == nil else { return } + + let urlString = try await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) + guard let url = URL(string: urlString) else { throw URLError(.badURL) } + + let task = session.downloadTask(with: url) + task.taskDescription = key + activeTasks[key] = task + + downloads[key] = DownloadProgress( + slug: slug, chapter: chapter, voice: voice, + progress: 0, totalBytes: 0, downloadedBytes: 0, status: .downloading) + task.resume() + } + + func cancelDownload(slug: String, chapter: Int, voice: String) { + let key = makeKey(slug: slug, chapter: chapter, voice: voice) + activeTasks[key]?.cancel() + activeTasks.removeValue(forKey: key) + downloads.removeValue(forKey: key) + } + + func deleteDownload(slug: String, chapter: Int, voice: String) throws { + let key = makeKey(slug: slug, chapter: chapter, voice: voice) + let fileURL = audioFileURL(slug: slug, chapter: chapter, voice: voice) + if fileManager.fileExists(atPath: fileURL.path) { + try fileManager.removeItem(at: fileURL) + } + downloadedChapters.remove(key) + downloads.removeValue(forKey: key) + saveMetadata() + } + + func deleteAllDownloads() throws { + if let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { + let audioDir = docs.appendingPathComponent("audio") + if fileManager.fileExists(atPath: audioDir.path) { + try fileManager.removeItem(at: audioDir) + } + } + downloadedChapters.removeAll() + downloads.removeAll() + activeTasks.values.forEach { $0.cancel() } + activeTasks.removeAll() + saveMetadata() + } + + func totalStorageUsed() -> Int64 { + guard let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return 0 } + let audioDir = docs.appendingPathComponent("audio") + guard let enumerator = fileManager.enumerator(at: audioDir, + includingPropertiesForKeys: [.fileSizeKey]) else { return 0 } + var total: Int64 = 0 + for case let url as URL in enumerator { + if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize { + total += Int64(size) + } + } + return total + } + + func offlineBookSlugs() -> [String] { + Array(Set(downloadedChapters.compactMap { key -> String? in + let parts = key.split(separator: "::") + return parts.count == 3 ? String(parts[0]) : nil + })).sorted() + } + + func downloadedChapterCount(for slug: String) -> Int { + downloadedChapters.filter { $0.hasPrefix("\(slug)::") }.count + } + + // MARK: - Key / path helpers + + func makeKey(slug: String, chapter: Int, voice: String) -> String { + "\(slug)::\(chapter)::\(voice)" + } + + nonisolated private func audioFileURL(slug: String, chapter: Int, voice: String) -> URL { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return docs + .appendingPathComponent("audio") + .appendingPathComponent(slug) + .appendingPathComponent("\(chapter)-\(voice).mp3") + } + + // MARK: - Persistence + + private func loadMetadata() { + if let data = UserDefaults.standard.data(forKey: metadataKey), + let decoded = try? JSONDecoder().decode(Set.self, from: data) { + downloadedChapters = decoded + } + } + + private func saveMetadata() { + if let encoded = try? JSONEncoder().encode(downloadedChapters) { + UserDefaults.standard.set(encoded, forKey: metadataKey) + } + } +} + +// MARK: - URLSessionDownloadDelegate + +extension AudioDownloadService: URLSessionDownloadDelegate { + + nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL) { + guard let key = downloadTask.taskDescription else { return } + let parts = key.split(separator: "::") + guard parts.count == 3, let chapter = Int(parts[1]) else { return } + let slug = String(parts[0]) + let voice = String(parts[2]) + let dest = audioFileURL(slug: slug, chapter: chapter, voice: voice) + + do { + let dir = dest.deletingLastPathComponent() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: dest.path) { + try FileManager.default.removeItem(at: dest) + } + try FileManager.default.moveItem(at: location, to: dest) + Task { @MainActor in + self.downloadedChapters.insert(key) + self.downloads.removeValue(forKey: key) + self.activeTasks.removeValue(forKey: key) + self.saveMetadata() + } + } catch { + Task { @MainActor in + self.downloads[key]?.status = .failed(error.localizedDescription) + self.activeTasks.removeValue(forKey: key) + } + } + } + + nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, + didWriteData _: Int64, totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64) { + guard let key = downloadTask.taskDescription else { return } + let progress = totalBytesExpectedToWrite > 0 + ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0 + Task { @MainActor in + if var p = self.downloads[key] { + p.downloadedBytes = totalBytesWritten + p.totalBytes = totalBytesExpectedToWrite + p.progress = progress + self.downloads[key] = p + } + } + } + + nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, + didCompleteWithError error: Error?) { + guard let key = task.taskDescription, let error else { return } + let nsErr = error as NSError + guard nsErr.code != NSURLErrorCancelled else { return } + Task { @MainActor in + self.downloads[key]?.status = .failed(error.localizedDescription) + self.activeTasks.removeValue(forKey: key) + } + } +} + +// MARK: - Supporting types + +struct DownloadProgress: Equatable { + let slug: String + let chapter: Int + let voice: String + var progress: Double + var totalBytes: Int64 + var downloadedBytes: Int64 + var status: DownloadStatus +} + +enum DownloadStatus: Equatable { + case downloading + case completed + case failed(String) +} diff --git a/ios/LibNovelV2/Services/AudioPlayerService.swift b/ios/LibNovelV2/Services/AudioPlayerService.swift new file mode 100644 index 0000000..03d441d --- /dev/null +++ b/ios/LibNovelV2/Services/AudioPlayerService.swift @@ -0,0 +1,492 @@ +import Foundation +import AVFoundation +import MediaPlayer +import Combine + +// MARK: - PlaybackProgress +// High-frequency playback state isolated into its own ObservableObject so that +// the 0.5-second time-observer ticks only invalidate views that explicitly +// subscribe to this object (seek bar, play/pause button), leaving menus and +// other stable UI untouched. + +@MainActor +final class PlaybackProgress: ObservableObject { + @Published var currentTime: Double = 0 + @Published var duration: Double = 0 + @Published var isPlaying: Bool = false +} + +// MARK: - AudioPlayerService +// Central singleton owning AVPlayer, lock-screen controls (NowPlayingInfoCenter +// + MPRemoteCommandCenter), and next-chapter prefetch. + +@MainActor +final class AudioPlayerService: ObservableObject { + + // MARK: - Published state + + @Published var slug: String = "" + @Published var chapter: Int = 0 + @Published var chapterTitle: String = "" + @Published var bookTitle: String = "" + @Published var coverURL: String = "" + @Published var voice: String = "af_bella" + @Published var speed: Double = 1.0 + @Published var chapters: [ChapterBrief] = [] + + @Published var status: AudioPlayerStatus = .idle + @Published var audioURL: String = "" + @Published var errorMessage: String = "" + @Published var generationProgress: Double = 0 + + /// High-frequency playback state — subscribe directly to avoid re-rendering parents. + let progress = PlaybackProgress() + + // Convenience forwarders for callers that don't need granular isolation. + var currentTime: Double { get { progress.currentTime } set { progress.currentTime = newValue } } + var duration: Double { get { progress.duration } set { progress.duration = newValue } } + var isPlaying: Bool { get { progress.isPlaying } set { progress.isPlaying = newValue } } + + @Published var autoNext: Bool = false + @Published var nextChapter: Int? = nil + @Published var prevChapter: Int? = nil + + @Published var sleepTimer: SleepTimerOption? = nil + @Published var sleepTimerRemainingText: String = "" + + @Published var nextPrefetchStatus: NextPrefetchStatus = .none + @Published var nextAudioURL: String = "" + @Published var nextPrefetchedChapter: Int? = nil + + var isActive: Bool { + if case .idle = status { return false } + return true + } + + // MARK: - Private + + private var player: AVPlayer? + private var playerItem: AVPlayerItem? + private var timeObserver: Any? + private var statusObserver: AnyCancellable? + private var durationObserver: AnyCancellable? + private var finishObserver: AnyCancellable? + private var generationTask: Task? + private var prefetchTask: Task? + + private var cachedCoverArtwork: MPMediaItemArtwork? + private var cachedCoverURL: String = "" + + private var sleepTimerTask: Task? + private var sleepTimerStartChapter: Int = 0 + private var sleepTimerDeadline: Date? = nil + private var sleepTimerCountdownTask: Task? = nil + + // MARK: - Init + + init() { + configureAudioSession() + setupRemoteCommandCenter() + } + + // MARK: - Public API + + func load(slug: String, chapter: Int, chapterTitle: String, + bookTitle: String, coverURL: String, voice: String, speed: Double, + chapters: [ChapterBrief], nextChapter: Int?, prevChapter: Int?) { + generationTask?.cancel() + prefetchTask?.cancel() + stop() + + self.slug = slug + self.chapter = chapter + self.chapterTitle = chapterTitle + self.bookTitle = bookTitle + self.coverURL = coverURL + self.voice = voice + self.speed = speed + self.chapters = chapters + self.nextChapter = nextChapter + self.prevChapter = prevChapter + self.nextPrefetchStatus = .none + self.nextAudioURL = "" + self.nextPrefetchedChapter = nil + + if case .chapters = sleepTimer { sleepTimerStartChapter = chapter } + + status = .generating + generationProgress = 0 + + if coverURL != cachedCoverURL { + cachedCoverArtwork = nil + cachedCoverURL = coverURL + Task { await prefetchCoverArtwork(from: coverURL) } + } + + generationTask = Task { await generateAudio() } + } + + func play() { + player?.play() + player?.rate = Float(speed) + isPlaying = true + updateNowPlaying() + } + + func pause() { + player?.pause() + isPlaying = false + updateNowPlaying() + } + + func togglePlayPause() { + isPlaying ? pause() : play() + } + + func seek(to seconds: Double) { + let time = CMTime(seconds: seconds, preferredTimescale: 600) + currentTime = seconds + player?.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in + guard let self else { return } + Task { @MainActor in self.updateNowPlaying() } + } + } + + func skip(by seconds: Double) { + seek(to: max(0, min(currentTime + seconds, duration))) + } + + func setSpeed(_ newSpeed: Double) { + speed = newSpeed + if isPlaying { player?.rate = Float(newSpeed) } + updateNowPlaying() + } + + func setSleepTimer(_ option: SleepTimerOption?) { + sleepTimerTask?.cancel(); sleepTimerTask = nil + sleepTimerCountdownTask?.cancel(); sleepTimerCountdownTask = nil + sleepTimerDeadline = nil + sleepTimer = option + + guard let option else { sleepTimerRemainingText = ""; return } + + switch option { + case .chapters(let count): + sleepTimerStartChapter = chapter + updateChapterTimerLabel(chaptersRemaining: count) + + case .minutes(let minutes): + let deadline = Date().addingTimeInterval(Double(minutes) * 60) + sleepTimerDeadline = deadline + sleepTimerTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(minutes) * 60 * 1_000_000_000) + guard let self, !Task.isCancelled else { return } + await MainActor.run { self.stop(); self.sleepTimer = nil; self.sleepTimerRemainingText = "" } + } + sleepTimerCountdownTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + guard let self, !Task.isCancelled else { return } + await MainActor.run { + guard let d = self.sleepTimerDeadline else { return } + self.sleepTimerRemainingText = Self.formatCountdown(max(0, d.timeIntervalSinceNow)) + } + } + } + sleepTimerRemainingText = Self.formatCountdown(Double(minutes) * 60) + } + } + + func stop() { + player?.pause() + teardownPlayer() + isPlaying = false + currentTime = 0 + duration = 0 + audioURL = "" + status = .idle + sleepTimerTask?.cancel(); sleepTimerTask = nil + sleepTimerCountdownTask?.cancel(); sleepTimerCountdownTask = nil + sleepTimerDeadline = nil + sleepTimer = nil + sleepTimerRemainingText = "" + } + + // MARK: - Private helpers + + private func updateChapterTimerLabel(chaptersRemaining: Int) { + sleepTimerRemainingText = chaptersRemaining == 1 ? "1 ch left" : "\(chaptersRemaining) ch left" + } + + private static func formatCountdown(_ seconds: Double) -> String { + let s = Int(max(0, seconds)) + return "\(s / 60):\(String(format: "%02d", s % 60))" + } + + // MARK: - Audio generation + + private func generateAudio() async { + guard !slug.isEmpty, chapter > 0 else { return } + + // Local file first (offline download) + if let localURL = AudioDownloadService.shared.localURL(slug: slug, chapter: chapter, voice: voice) { + audioURL = localURL.absoluteString + status = .ready + generationProgress = 100 + await playURL(localURL.absoluteString) + await prefetchNext() + return + } + + do { + // Fast path: audio already in MinIO + if let presigned = try? await APIClient.shared.presignAudio( + slug: slug, chapter: chapter, voice: voice) { + audioURL = presigned + status = .ready + generationProgress = 100 + await playURL(presigned) + await prefetchNext() + return + } + + // Slow path: trigger TTS generation + status = .generating + generationProgress = 10 + let trigger = try await APIClient.shared.triggerAudio( + slug: slug, chapter: chapter, voice: voice, speed: speed) + + let playableURL: String + if trigger.isAsync { + generationProgress = 30 + playableURL = try await APIClient.shared.pollAudioStatus( + slug: slug, chapter: chapter, voice: voice) + } else { + guard let url = trigger.url, !url.isEmpty else { throw URLError(.badServerResponse) } + playableURL = url + } + + audioURL = playableURL + status = .ready + generationProgress = 100 + await playURL(playableURL) + await prefetchNext() + } catch is CancellationError { + // Cancelled — no-op + } catch { + status = .error(error.localizedDescription) + errorMessage = error.localizedDescription + } + } + + // MARK: - Prefetch next chapter + + private func prefetchNext() async { + guard let next = nextChapter, !Task.isCancelled else { return } + nextPrefetchStatus = .prefetching + nextPrefetchedChapter = next + do { + if let presigned = try? await APIClient.shared.presignAudio( + slug: slug, chapter: next, voice: voice) { + nextAudioURL = presigned + nextPrefetchStatus = .prefetched + return + } + let trigger = try await APIClient.shared.triggerAudio( + slug: slug, chapter: next, voice: voice, speed: speed) + let url: String + if trigger.isAsync { + url = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: next, voice: voice) + } else { + guard let u = trigger.url, !u.isEmpty else { throw URLError(.badServerResponse) } + url = u + } + nextAudioURL = url + nextPrefetchStatus = .prefetched + } catch { + nextPrefetchStatus = .failed + } + } + + // MARK: - AVPlayer management + + private func playURL(_ urlString: String) async { + let resolved: URL? + if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") { + resolved = URL(string: urlString) + } else { + resolved = URL(string: urlString, + relativeTo: await APIClient.shared.baseURL)?.absoluteURL + } + guard let url = resolved else { return } + + teardownPlayer() + let item = AVPlayerItem(url: url) + playerItem = item + player = AVPlayer(playerItem: item) + + durationObserver = item.publisher(for: \.duration) + .receive(on: RunLoop.main) + .sink { [weak self] dur in + guard let self else { return } + let secs = dur.seconds + if secs.isFinite && secs > 0 { self.duration = secs; self.updateNowPlaying() } + } + + statusObserver = item.publisher(for: \.status) + .receive(on: RunLoop.main) + .sink { [weak self] s in + guard let self else { return } + switch s { + case .readyToPlay: + self.player?.rate = Float(self.speed) + self.isPlaying = true + self.updateNowPlaying() + case .failed: + self.status = .error(item.error?.localizedDescription ?? "Playback failed") + self.errorMessage = item.error?.localizedDescription ?? "Playback failed" + default: break + } + } + + timeObserver = player?.addPeriodicTimeObserver( + forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), + queue: .main + ) { [weak self] time in + guard let self else { return } + Task { @MainActor in + let secs = time.seconds + if secs.isFinite && secs >= 0 { self.currentTime = secs } + } + } + + finishObserver = NotificationCenter.default + .publisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: item) + .sink { [weak self] _ in Task { @MainActor in self?.handlePlaybackFinished() } } + + player?.play() + } + + private func teardownPlayer() { + if let obs = timeObserver { player?.removeTimeObserver(obs) } + timeObserver = nil; statusObserver = nil; durationObserver = nil; finishObserver = nil + player = nil; playerItem = nil + } + + private func handlePlaybackFinished() { + isPlaying = false + guard let next = nextChapter else { return } + + // Chapter-based sleep timer + if case .chapters(let count) = sleepTimer { + let played = chapter - sleepTimerStartChapter + 1 + if played >= count { stop(); return } + updateChapterTimerLabel(chaptersRemaining: count - played) + } + + NotificationCenter.default.post( + name: .audioDidFinishChapter, object: nil, + userInfo: ["next": next, "autoNext": autoNext]) + + guard autoNext else { return } + + let nextTitle = chapters.first(where: { $0.number == next })?.title ?? "" + let nextNextChapter = chapters.first(where: { $0.number > next })?.number + + if nextPrefetchStatus == .prefetched, !nextAudioURL.isEmpty { + let url = nextAudioURL + chapter = next + chapterTitle = nextTitle + nextChapter = nextNextChapter + prevChapter = chapter + nextPrefetchStatus = .none + nextAudioURL = "" + nextPrefetchedChapter = nil + audioURL = url + status = .ready + generationProgress = 100 + if case .chapters = sleepTimer { sleepTimerStartChapter = next } + generationTask = Task { await playURL(url); await prefetchNext() } + } else { + load(slug: slug, chapter: next, chapterTitle: nextTitle, + bookTitle: bookTitle, coverURL: coverURL, + voice: voice, speed: speed, chapters: chapters, + nextChapter: nextNextChapter, prevChapter: chapter) + } + } + + // MARK: - Cover art (URLSession — no Kingfisher) + + private func prefetchCoverArtwork(from urlString: String) async { + guard !urlString.isEmpty, let url = URL(string: urlString) else { return } + guard let (data, _) = try? await URLSession.shared.data(from: url), + let image = UIImage(data: data) else { return } + let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + cachedCoverArtwork = artwork + updateNowPlaying() + } + + // MARK: - Audio session + + private func configureAudioSession() { + try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio) + try? AVAudioSession.sharedInstance().setActive(true) + } + + // MARK: - Lock-screen controls + + private func setupRemoteCommandCenter() { + let center = MPRemoteCommandCenter.shared() + center.playCommand.addTarget { [weak self] _ in self?.play(); return .success } + center.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success } + center.togglePlayPauseCommand.addTarget { [weak self] _ in self?.togglePlayPause(); return .success } + center.skipForwardCommand.preferredIntervals = [15] + center.skipForwardCommand.addTarget { [weak self] _ in self?.skip(by: 15); return .success } + center.skipBackwardCommand.preferredIntervals = [15] + center.skipBackwardCommand.addTarget { [weak self] _ in self?.skip(by: -15); return .success } + center.changePlaybackPositionCommand.addTarget { [weak self] event in + if let e = event as? MPChangePlaybackPositionCommandEvent { self?.seek(to: e.positionTime) } + return .success + } + } + + private func updateNowPlaying() { + var info: [String: Any] = [ + MPMediaItemPropertyTitle: chapterTitle.isEmpty ? "Chapter \(chapter)" : chapterTitle, + MPMediaItemPropertyArtist: bookTitle, + MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime, + MPMediaItemPropertyPlaybackDuration: duration, + MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? speed : 0.0 + ] + if let artwork = cachedCoverArtwork { info[MPMediaItemPropertyArtwork] = artwork } + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } +} + +// MARK: - Supporting types + +enum AudioPlayerStatus: Equatable { + case idle + case generating + case ready + case error(String) + + static func == (lhs: AudioPlayerStatus, rhs: AudioPlayerStatus) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle), (.generating, .generating), (.ready, .ready): return true + case (.error(let a), .error(let b)): return a == b + default: return false + } + } +} + +enum SleepTimerOption: Equatable { + case chapters(Int) + case minutes(Int) +} + +extension Notification.Name { + static let audioDidFinishChapter = Notification.Name("v2.audioDidFinishChapter") + static let skipToNextChapter = Notification.Name("v2.skipToNextChapter") + static let skipToPrevChapter = Notification.Name("v2.skipToPrevChapter") +} diff --git a/ios/LibNovelV2/Services/AuthStore.swift b/ios/LibNovelV2/Services/AuthStore.swift new file mode 100644 index 0000000..ceebede --- /dev/null +++ b/ios/LibNovelV2/Services/AuthStore.swift @@ -0,0 +1,144 @@ +import Foundation +import Combine + +// MARK: - AuthStore +// Owns the authenticated user, the HMAC auth token, and user settings. +// Persists the token to Keychain so the user stays logged in across launches. + +@MainActor +final class AuthStore: ObservableObject { + @Published var user: AppUser? + @Published var settings: UserSettings = .default + @Published var isLoading: Bool = false + @Published var error: String? + + var isAuthenticated: Bool { user != nil } + + private let keychainKey = "libnovel_v2_auth_token" + + init() { + if let token = loadToken() { + Task { await validateToken(token) } + } + } + + // MARK: - Login / Register + + func login(username: String, password: String) async { + isLoading = true + error = nil + do { + let response = try await APIClient.shared.login(username: username, password: password) + await APIClient.shared.setAuthCookie(response.token) + saveToken(response.token) + user = response.user + await loadSettings() + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func register(username: String, password: String) async { + isLoading = true + error = nil + do { + let response = try await APIClient.shared.register(username: username, password: password) + await APIClient.shared.setAuthCookie(response.token) + saveToken(response.token) + user = response.user + await loadSettings() + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func logout() async { + do { try await APIClient.shared.logout() } catch {} + clearToken() + user = nil + settings = .default + } + + // MARK: - Settings + + func loadSettings() async { + do { settings = try await APIClient.shared.settings() } catch {} + } + + func saveSettings(_ updated: UserSettings) async { + do { + try await APIClient.shared.updateSettings(updated) + settings = updated + } catch { + self.error = error.localizedDescription + } + } + + // MARK: - Token validation + + func validateToken() async { + guard let token = loadToken() else { return } + await validateToken(token) + } + + private func validateToken(_ token: String) async { + await APIClient.shared.setAuthCookie(token) + do { + async let me: AppUser = APIClient.shared.fetch("/api/auth/me") + async let s: UserSettings = APIClient.shared.settings() + var (restoredUser, restoredSettings) = try await (me, s) + // Exchange raw MinIO key for a presigned URL if needed. + if let key = restoredUser.avatarURL, !key.hasPrefix("http") { + if let presignedURL = try? await APIClient.shared.fetchAvatarPresignedURL() { + restoredUser = AppUser( + id: restoredUser.id, + username: restoredUser.username, + role: restoredUser.role, + created: restoredUser.created, + avatarURL: presignedURL + ) + } + } + user = restoredUser + settings = restoredSettings + } catch let e as APIError { + if case .httpError(let code, _) = e, code == 401 { clearToken() } + } catch {} + } + + // MARK: - Keychain helpers + + private func saveToken(_ token: String) { + let data = Data(token.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: keychainKey, + kSecValueData as String: data + ] + SecItemDelete(query as CFDictionary) + SecItemAdd(query as CFDictionary, nil) + } + + private func loadToken() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: keychainKey, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private func clearToken() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: keychainKey + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/ios/LibNovelV2/Services/BookVoicePreferences.swift b/ios/LibNovelV2/Services/BookVoicePreferences.swift new file mode 100644 index 0000000..f817c4f --- /dev/null +++ b/ios/LibNovelV2/Services/BookVoicePreferences.swift @@ -0,0 +1,59 @@ +import Foundation + +// MARK: - BookVoicePreferences +// Manages per-book voice overrides with global fallback. +// Persisted in UserDefaults as a slug → voice dictionary. + +@MainActor +final class BookVoicePreferences: ObservableObject { + static let shared = BookVoicePreferences() + + @Published private(set) var bookVoices: [String: String] = [:] + + private let key = "v2.bookVoicePreferences" + + private init() { + if let data = UserDefaults.standard.data(forKey: key), + let decoded = try? JSONDecoder().decode([String: String].self, from: data) { + bookVoices = decoded + } + } + + // MARK: - Public API + + func voice(for slug: String) -> String? { + bookVoices[slug] + } + + /// Voice priority: book override → globalVoice → "af_bella" + func voiceWithFallback(for slug: String, globalVoice: String) -> String { + bookVoices[slug] ?? globalVoice + } + + func setVoice(_ voice: String, for slug: String) { + bookVoices[slug] = voice + save() + } + + func removeVoice(for slug: String) { + bookVoices.removeValue(forKey: slug) + save() + } + + func hasOverride(for slug: String) -> Bool { + bookVoices[slug] != nil + } + + func clearAll() { + bookVoices.removeAll() + save() + } + + // MARK: - Persistence + + private func save() { + if let encoded = try? JSONEncoder().encode(bookVoices) { + UserDefaults.standard.set(encoded, forKey: key) + } + } +} diff --git a/ios/LibNovelV2/Services/NetworkMonitor.swift b/ios/LibNovelV2/Services/NetworkMonitor.swift new file mode 100644 index 0000000..d1a0800 --- /dev/null +++ b/ios/LibNovelV2/Services/NetworkMonitor.swift @@ -0,0 +1,43 @@ +import Foundation +import Network + +// MARK: - NetworkMonitor +// Monitors network connectivity. Inject as an environment object for offline UI. + +@MainActor +final class NetworkMonitor: ObservableObject { + static let shared = NetworkMonitor() + + @Published var isConnected: Bool = true + @Published var connectionType: NWInterface.InterfaceType? + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "cc.kalekber.libnovel.v2.network-monitor") + + init() { + monitor.pathUpdateHandler = { [weak self] path in + Task { @MainActor [weak self] in + self?.isConnected = path.status == .satisfied + self?.connectionType = path.availableInterfaces.first?.type + } + } + monitor.start(queue: queue) + } + + deinit { + monitor.cancel() + } +} + +extension NWInterface.InterfaceType { + var displayName: String { + switch self { + case .wifi: return "Wi-Fi" + case .cellular: return "Cellular" + case .wiredEthernet: return "Ethernet" + case .loopback: return "Loopback" + case .other: return "Other" + @unknown default: return "Unknown" + } + } +} diff --git a/ios/LibNovelV2/ViewModels/BookDetailViewModel.swift b/ios/LibNovelV2/ViewModels/BookDetailViewModel.swift new file mode 100644 index 0000000..f337b20 --- /dev/null +++ b/ios/LibNovelV2/ViewModels/BookDetailViewModel.swift @@ -0,0 +1,80 @@ +import Foundation + +// MARK: - BookDetailViewModel +// Loads book metadata, chapter index, save state, and reading progress. +// Uses @Observable (iOS 17+). + +@Observable +@MainActor +final class BookDetailViewModel { + let slug: String + + var book: Book? + var chapters: [ChapterIndex] = [] + var inLib: Bool = false + var saved: Bool = false + var lastChapter: Int? + + var isLoading = false + var isSaving = false + var error: String? + + init(slug: String) { + self.slug = slug + } + + // MARK: - Load + + func load() async { + guard !isLoading else { return } + isLoading = true + error = nil + do { + let response = try await APIClient.shared.bookDetail(slug: slug) + book = response.book + chapters = response.chapters + inLib = response.inLib + saved = response.saved + lastChapter = response.lastChapter + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } + + // MARK: - Toggle saved (bookmark) + + func toggleSaved() async { + guard !isSaving else { return } + isSaving = true + let targetSaved = !saved + saved = targetSaved // optimistic update + do { + if targetSaved { + try await APIClient.shared.saveBook(slug: slug) + if !inLib { inLib = true } + } else { + try await APIClient.shared.unsaveBook(slug: slug) + } + } catch { + saved = !targetSaved // revert on failure + self.error = error.localizedDescription + } + isSaving = false + } + + // MARK: - Chapter helpers + + /// Title stripped of trailing " - Month DD YYYY" date suffixes. + func displayTitle(for chapter: ChapterIndex) -> String { + let stripped = chapter.title.strippingTrailingDate() + if stripped.isEmpty || stripped == "Chapter \(chapter.number)" { + return "Chapter \(chapter.number)" + } + return stripped + } +} + + diff --git a/ios/LibNovelV2/ViewModels/BrowseViewModel.swift b/ios/LibNovelV2/ViewModels/BrowseViewModel.swift new file mode 100644 index 0000000..7b78e9d --- /dev/null +++ b/ios/LibNovelV2/ViewModels/BrowseViewModel.swift @@ -0,0 +1,146 @@ +import Foundation + +// MARK: - BrowseViewModel +// Powers both the Discover shelves (BrowseView) and the full paginated grid (BrowseCategoryView). +// Uses @Observable (iOS 17+). + +@Observable +@MainActor +final class BrowseViewModel { + + // MARK: - Discover shelves (BrowseView) + + var trending: [BrowseNovel] = [] + var newReleases: [BrowseNovel] = [] + var recentlyUpdated: [BrowseNovel] = [] + var ranking: [BrowseNovel] = [] + + // MARK: - Paginated grid (BrowseCategoryView) + + var novels: [BrowseNovel] = [] + var currentPage = 1 + var hasNext = false + + // Filter params (BrowseCategoryView sets these before calling loadFirstPage) + var sort: String = "popular" + var genre: String = "all" + var status: String = "all" + + // MARK: - UI state + + var isLoading = false + var isLoadingMore = false + var error: String? + + // MARK: - Discover load (fetches multiple shelves in parallel) + + func loadShelves() async { + isLoading = true + error = nil + + do { + async let trendingTask = APIClient.shared.browse(page: 1, genre: "all", sort: "popular", status: "all") + async let newTask = APIClient.shared.browse(page: 1, genre: "all", sort: "new", status: "all") + async let updatedTask = APIClient.shared.browse(page: 1, genre: "all", sort: "update", status: "all") + async let rankingTask = APIClient.shared.ranking() + + let (trendingResp, newResp, updatedResp, rankItems) = try await ( + trendingTask, newTask, updatedTask, rankingTask + ) + + trending = Array(trendingResp.novels.prefix(12)) + newReleases = Array(newResp.novels.prefix(12)) + recentlyUpdated = Array(updatedResp.novels.prefix(12)) + ranking = rankItems.prefix(12).map { item in + BrowseNovelFromRanking(item) + } + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } + + // MARK: - Paginated category load + + func loadFirstPage() async { + guard !isLoading else { return } + novels = [] + currentPage = 1 + hasNext = false + isLoading = true + error = nil + + do { + let resp = try await APIClient.shared.browse( + page: 1, genre: genre, sort: sort, status: status) + novels = resp.novels + currentPage = resp.page + hasNext = resp.hasNext + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } + + func loadNextPage() async { + guard hasNext, !isLoadingMore, !isLoading else { return } + isLoadingMore = true + + let next = currentPage + 1 + do { + let resp = try await APIClient.shared.browse( + page: next, genre: genre, sort: sort, status: status) + novels += resp.novels + currentPage = resp.page + hasNext = resp.hasNext + } catch { + // Silently ignore — user can scroll again + } + isLoadingMore = false + } + + // MARK: - Ranking load (for rank sort mode) + + func loadRanking() async { + guard !isLoading else { return } + novels = [] + hasNext = false + isLoading = true + error = nil + + do { + let items = try await APIClient.shared.ranking() + novels = items.map { BrowseNovelFromRanking($0) } + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } +} + +// MARK: - RankingItem → BrowseNovel adapter + +private func BrowseNovelFromRanking(_ item: RankingItem) -> BrowseNovel { + // Synthesise a minimal JSON blob so we can decode via the standard init + let rankStr = "#\(item.rank)" + let dict: [String: Any] = [ + "slug": item.slug, + "title": item.title, + "cover": item.cover, + "rank": rankStr, + "rating": "", + "chapters": "", + "url": item.sourceURL, + "author": item.author, + "status": item.status, + "genres": item.genres + ] + let data = try! JSONSerialization.data(withJSONObject: dict) + return try! JSONDecoder.apiDecoder.decode(BrowseNovel.self, from: data) +} diff --git a/ios/LibNovelV2/ViewModels/ChapterReaderViewModel.swift b/ios/LibNovelV2/ViewModels/ChapterReaderViewModel.swift new file mode 100644 index 0000000..f32fa2e --- /dev/null +++ b/ios/LibNovelV2/ViewModels/ChapterReaderViewModel.swift @@ -0,0 +1,69 @@ +import Foundation + +// MARK: - ChapterReaderViewModel + +@Observable @MainActor +final class ChapterReaderViewModel { + let slug: String + private(set) var chapter: Int + + var content: ChapterResponse? + var isLoading = false + var error: String? + + init(slug: String, chapter: Int) { + self.slug = slug + self.chapter = chapter + } + + /// Switch to a different chapter in-place; `chapter` change causes `.task(id: chapter)` to re-fire `load()`. + func switchChapter(to newChapter: Int) { + guard newChapter != chapter else { return } + chapter = newChapter + content = nil + error = nil + } + + func load() async { + isLoading = true + error = nil + do { + content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter) + try? await APIClient.shared.setProgress(slug: slug, chapter: chapter) + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } + + func toggleAudio(audioPlayer: AudioPlayerService, settings: UserSettings) { + guard let content else { return } + + let isCurrent = audioPlayer.isActive + && audioPlayer.slug == slug + && audioPlayer.chapter == chapter + + if isCurrent { + audioPlayer.togglePlayPause() + } else { + let voice = BookVoicePreferences.shared.voiceWithFallback( + for: slug, + globalVoice: settings.voice + ) + audioPlayer.load( + slug: slug, + chapter: chapter, + chapterTitle: content.chapter.title, + bookTitle: content.book.title, + coverURL: content.book.cover, + voice: voice, + speed: settings.speed, + chapters: content.chapters, + nextChapter: content.next, + prevChapter: content.prev + ) + } + } +} diff --git a/ios/LibNovelV2/ViewModels/HomeViewModel.swift b/ios/LibNovelV2/ViewModels/HomeViewModel.swift new file mode 100644 index 0000000..2a8cb7d --- /dev/null +++ b/ios/LibNovelV2/ViewModels/HomeViewModel.swift @@ -0,0 +1,35 @@ +import Foundation + +// MARK: - HomeViewModel +// Fetches home-screen data: continue reading, recently updated, stats, subscription feed. +// Uses @Observable (iOS 17+). + +@Observable +@MainActor +final class HomeViewModel { + var continueReading: [ContinueReadingItem] = [] + var recentlyUpdated: [Book] = [] + var stats: HomeStats? + var subscriptionFeed: [SubscriptionFeedItem] = [] + var isLoading = false + var error: String? + + func load() async { + isLoading = true + error = nil + do { + let data = try await APIClient.shared.homeData() + continueReading = data.continueReading.map { + ContinueReadingItem(book: $0.book, chapter: $0.chapter) + } + recentlyUpdated = data.recentlyUpdated + stats = data.stats + subscriptionFeed = data.subscriptionFeed + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } +} diff --git a/ios/LibNovelV2/ViewModels/LibraryViewModel.swift b/ios/LibNovelV2/ViewModels/LibraryViewModel.swift new file mode 100644 index 0000000..1ae7765 --- /dev/null +++ b/ios/LibNovelV2/ViewModels/LibraryViewModel.swift @@ -0,0 +1,164 @@ +import Foundation + +// MARK: - LibraryViewModel +// Loads library items and exposes filtered/sorted views for LibraryView. +// Uses @Observable (iOS 17+). + +enum LibrarySortOrder: String, CaseIterable { + case recent = "Recent" + case title = "Title" + case author = "Author" + case progress = "Progress" +} + +enum LibraryReadingFilter: String, CaseIterable { + case all = "All" + case inProgress = "In Progress" + case completed = "Completed" +} + +@Observable +@MainActor +final class LibraryViewModel { + // Raw data + var items: [LibraryItem] = [] + var progressMap: [String: Int] = [:] // slug -> last chapter read + + // Filter & sort state + var sortOrder: LibrarySortOrder = .recent + var readingFilter: LibraryReadingFilter = .all + var selectedGenre: String = "All" + + // UI state + var isLoading = false + var error: String? + + // MARK: - Derived + + var allGenres: [String] { + var seen = Set() + var result: [String] = ["All"] + for item in items { + for genre in item.book.genres where !seen.contains(genre) { + seen.insert(genre) + result.append(genre) + } + } + return result + } + + var filteredItems: [LibraryItem] { + var list = items + + // Genre filter + if selectedGenre != "All" { + list = list.filter { $0.book.genres.contains(selectedGenre) } + } + + // Reading filter + switch readingFilter { + case .all: + break + case .inProgress: + list = list.filter { item in + let ch = progressMap[item.book.slug] ?? item.lastChapter ?? 0 + return ch > 0 && ch < item.book.totalChapters + } + case .completed: + list = list.filter { item in + let ch = progressMap[item.book.slug] ?? item.lastChapter ?? 0 + return item.book.totalChapters > 0 && ch >= item.book.totalChapters + } + } + + // Sort + switch sortOrder { + case .recent: + // server already returns newest-saved first; preserve order + break + case .title: + list.sort { $0.book.title.localizedCaseInsensitiveCompare($1.book.title) == .orderedAscending } + case .author: + list.sort { $0.book.author.localizedCaseInsensitiveCompare($1.book.author) == .orderedAscending } + case .progress: + list.sort { a, b in + let pa = progressFraction(for: a) + let pb = progressFraction(for: b) + return pa > pb + } + } + + return list + } + + // MARK: - Progress helpers + + func lastChapter(for item: LibraryItem) -> Int { + progressMap[item.book.slug] ?? item.lastChapter ?? 0 + } + + func progressFraction(for item: LibraryItem) -> Double { + let total = item.book.totalChapters + guard total > 0 else { return 0 } + return Double(lastChapter(for: item)) / Double(total) + } + + func progressPercent(for item: LibraryItem) -> String { + let fraction = progressFraction(for: item) + let pct = fraction * 100 + if pct < 10 { + return String(format: "%.1f%%", pct) + } else { + return String(format: "%.0f%%", pct) + } + } + + func isCompleted(for item: LibraryItem) -> Bool { + let total = item.book.totalChapters + guard total > 0 else { return false } + return lastChapter(for: item) >= total + } + + // MARK: - Load + + func load() async { + isLoading = true + error = nil + do { + async let libraryTask = APIClient.shared.library() + async let progressTask = APIClient.shared.progress() + + let (library, progressEntries) = try await (libraryTask, progressTask) + items = library + progressMap = Dictionary(uniqueKeysWithValues: progressEntries.map { ($0.slug, $0.chapter) }) + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } + + // MARK: - Mutations + + func removeFromLibrary(slug: String) async { + // Optimistic remove + items.removeAll { $0.book.slug == slug } + do { + try await APIClient.shared.unsaveBook(slug: slug) + } catch { + // Silently fail — user can pull-to-refresh to restore + } + } + + func markFinished(item: LibraryItem) async { + let total = item.book.totalChapters + guard total > 0 else { return } + progressMap[item.book.slug] = total + do { + try await APIClient.shared.setProgress(slug: item.book.slug, chapter: total) + } catch { + // Silently fail + } + } +} diff --git a/ios/LibNovelV2/ViewModels/SearchViewModel.swift b/ios/LibNovelV2/ViewModels/SearchViewModel.swift new file mode 100644 index 0000000..dc18be3 --- /dev/null +++ b/ios/LibNovelV2/ViewModels/SearchViewModel.swift @@ -0,0 +1,115 @@ +import Foundation + +// MARK: - SearchViewModel +// Debounced live search (300 ms) + recent searches persisted in UserDefaults. +// Uses @Observable (iOS 17+). + +@Observable +@MainActor +final class SearchViewModel { + var query: String = "" + var results: [BrowseNovel] = [] + var localCount: Int = 0 + var remoteCount: Int = 0 + var isLoading = false + var error: String? + + // Persisted recent searches (max 10, prefixed with "v2.") + var recentSearches: [String] = [] + + private let recentKey = "v2.searchRecentTerms" + private var searchTask: Task? + + init() { + recentSearches = UserDefaults.standard.stringArray(forKey: recentKey) ?? [] + } + + // MARK: - Query change (debounced) + + func onQueryChange(_ newValue: String) { + searchTask?.cancel() + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + results = [] + localCount = 0 + remoteCount = 0 + return + } + searchTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) // 300 ms debounce + guard !Task.isCancelled else { return } + await runSearch(trimmed) + } + } + + // MARK: - Submit (immediate, saves to recent) + + func submitSearch() { + let term = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !term.isEmpty else { return } + saveRecent(term) + searchTask?.cancel() + searchTask = Task { await runSearch(term) } + } + + // MARK: - Recent search tap + + func selectRecent(_ term: String) { + query = term + searchTask?.cancel() + searchTask = Task { await runSearch(term) } + } + + // MARK: - Clear + + func clear() { + query = "" + results = [] + localCount = 0 + remoteCount = 0 + error = nil + searchTask?.cancel() + } + + func clearRecent() { + recentSearches = [] + UserDefaults.standard.removeObject(forKey: recentKey) + } + + // MARK: - Core search + + private func runSearch(_ term: String) async { + guard !term.isEmpty else { + results = [] + return + } + isLoading = true + error = nil + do { + let response = try await APIClient.shared.search(query: term) + // Only update if the query hasn't changed since we started + let currentTrimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + if currentTrimmed == term || currentTrimmed.isEmpty { + results = response.results + localCount = response.localCount + remoteCount = response.remoteCount + } + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + results = [] + } + } + isLoading = false + } + + // MARK: - Persist recent + + private func saveRecent(_ term: String) { + var list = recentSearches.filter { $0 != term } + list.insert(term, at: 0) + if list.count > 10 { list = Array(list.prefix(10)) } + recentSearches = list + UserDefaults.standard.set(list, forKey: recentKey) + } +} diff --git a/ios/LibNovelV2/Views/Auth/AuthView.swift b/ios/LibNovelV2/Views/Auth/AuthView.swift new file mode 100644 index 0000000..b046ef6 --- /dev/null +++ b/ios/LibNovelV2/Views/Auth/AuthView.swift @@ -0,0 +1,386 @@ +import SwiftUI + +// MARK: - AuthView +// Full-screen login / register view. +// Mirrors the web UI's login page: zinc-900 background, tab switcher with +// amber underline indicator, zinc-800 text fields with amber focus ring, +// amber CTA button, inline error banner, loading state. + +struct AuthView: View { + @EnvironmentObject var authStore: AuthStore + @EnvironmentObject var networkMonitor: NetworkMonitor + + @State private var mode: AuthMode = .login + + // Login fields + @State private var loginUsername: String = "" + @State private var loginPassword: String = "" + + // Register fields + @State private var regUsername: String = "" + @State private var regPassword: String = "" + @State private var regConfirm: String = "" + + // Focus management + @FocusState private var focus: AuthField? + + // Local validation error (client-side, e.g. password mismatch) + @State private var localError: String? + + private var displayError: String? { localError ?? authStore.error } + + var body: some View { + ZStack { + Color.appBackground.ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + Spacer(minLength: 60) + + // ── Wordmark ────────────────────────────────────────── + wordmark + + Spacer(minLength: 48) + + // ── Card ────────────────────────────────────────────── + VStack(spacing: 0) { + tabSwitcher + formContent + } + .background(Color.cardBackground) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .padding(.horizontal, 24) + + Spacer(minLength: 40) + } + } + .scrollDismissesKeyboard(.interactively) + } + .onChange(of: mode) { _, _ in + localError = nil + authStore.error = nil + } + } + + // MARK: - Wordmark + + private var wordmark: some View { + VStack(spacing: 6) { + Image(systemName: "books.vertical.fill") + .font(.system(size: 44)) + .foregroundStyle(Color.amber) + .symbolEffect(.bounce, value: mode) + + Text("libnovel") + .font(.title.bold()) + .fontDesign(.serif) + .foregroundStyle(.primary) + } + } + + // MARK: - Tab switcher + + private var tabSwitcher: some View { + HStack(spacing: 0) { + tabButton(label: "Sign in", tab: .login) + tabButton(label: "Create account", tab: .register) + } + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.cardBorder) + .frame(height: 1) + } + } + + @ViewBuilder + private func tabButton(label: String, tab: AuthMode) -> some View { + let isActive = mode == tab + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + mode = tab + } + } label: { + VStack(spacing: 0) { + Text(label) + .font(.subheadline.weight(.medium)) + .foregroundStyle(isActive ? Color.amber : Color.secondary) + .padding(.vertical, 14) + .frame(maxWidth: .infinity) + + // Active underline indicator + Rectangle() + .fill(isActive ? Color.amber : Color.clear) + .frame(height: 2) + .offset(y: 1) // sits on top of the border + } + } + .accessibilityAddTraits(isActive ? [.isSelected] : []) + } + + // MARK: - Form content + + @ViewBuilder + private var formContent: some View { + VStack(spacing: 16) { + // Error banner + if let err = displayError { + errorBanner(err) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + switch mode { + case .login: loginForm + case .register: registerForm + } + } + .padding(20) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: displayError) + .animation(.spring(response: 0.35, dampingFraction: 0.75), value: mode) + } + + // MARK: - Error banner + + private func errorBanner(_ message: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.errorText) + .font(.footnote) + Text(message) + .font(.footnote) + .foregroundStyle(Color.errorText) + .multilineTextAlignment(.leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.errorBackground) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.errorBorder, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + // MARK: - Login form + + private var loginForm: some View { + VStack(spacing: 16) { + AuthInputField( + label: "Username", + placeholder: "your_username", + text: $loginUsername, + contentType: .username, + keyboardType: .default, + focusState: $focus, + field: .loginUsername, + nextField: .loginPassword + ) + + AuthInputField( + label: "Password", + placeholder: "••••••••", + text: $loginPassword, + contentType: .password, + isSecure: true, + focusState: $focus, + field: .loginPassword, + onSubmit: submitLogin + ) + + ctaButton(label: "Sign in", action: submitLogin) + } + } + + // MARK: - Register form + + private var registerForm: some View { + VStack(spacing: 16) { + VStack(spacing: 4) { + AuthInputField( + label: "Username", + placeholder: "your_username", + text: $regUsername, + contentType: .username, + focusState: $focus, + field: .regUsername, + nextField: .regPassword + ) + Text("3–32 characters: letters, numbers, _ or -") + .font(.caption) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 2) + } + + VStack(spacing: 4) { + AuthInputField( + label: "Password", + placeholder: "••••••••", + text: $regPassword, + contentType: .newPassword, + isSecure: true, + focusState: $focus, + field: .regPassword, + nextField: .regConfirm + ) + Text("At least 8 characters") + .font(.caption) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 2) + } + + AuthInputField( + label: "Confirm password", + placeholder: "••••••••", + text: $regConfirm, + contentType: .newPassword, + isSecure: true, + focusState: $focus, + field: .regConfirm, + onSubmit: submitRegister + ) + + ctaButton(label: "Create account", action: submitRegister) + } + } + + // MARK: - CTA button + + private func ctaButton(label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + ZStack { + if authStore.isLoading { + ProgressView() + .tint(Color(uiColor: .systemBackground)) + } else { + Text(label) + .font(.subheadline.bold()) + .foregroundStyle(Color.ctaText) + } + } + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .background(Color.amber) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .disabled(authStore.isLoading || !networkMonitor.isConnected) + .opacity(authStore.isLoading ? 0.8 : 1) + .animation(.easeInOut(duration: 0.15), value: authStore.isLoading) + .accessibilityLabel(label) + } + + // MARK: - Actions + + private func submitLogin() { + guard !authStore.isLoading else { return } + localError = nil + focus = nil + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + Task { await authStore.login(username: loginUsername, password: loginPassword) } + } + + private func submitRegister() { + guard !authStore.isLoading else { return } + localError = nil + // Client-side validation + if regUsername.count < 3 || regUsername.count > 32 { + localError = "Username must be 3–32 characters." + return + } + if regPassword.count < 8 { + localError = "Password must be at least 8 characters." + return + } + if regPassword != regConfirm { + localError = "Passwords do not match." + return + } + focus = nil + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + Task { await authStore.register(username: regUsername, password: regPassword) } + } +} + +// MARK: - Auth mode enum + +private enum AuthMode: Equatable { case login, register } + +// MARK: - Focus field enum + +private enum AuthField: Hashable { + case loginUsername, loginPassword + case regUsername, regPassword, regConfirm +} + +// MARK: - AuthInputField component + +private struct AuthInputField: View { + let label: String + let placeholder: String + @Binding var text: String + var contentType: UITextContentType? = nil + var keyboardType: UIKeyboardType = .default + var isSecure: Bool = false + @FocusState.Binding var focusState: AuthField? + let field: AuthField + var nextField: AuthField? = nil + var onSubmit: (() -> Void)? = nil + + private var isFocused: Bool { focusState == field } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + + Group { + if isSecure { + SecureField(placeholder, text: $text) + } else { + TextField(placeholder, text: $text) + .keyboardType(keyboardType) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + } + .textContentType(contentType) + .focused($focusState, equals: field) + .submitLabel(nextField != nil ? .next : .done) + .onSubmit { + if let next = nextField { + focusState = next + } else { + onSubmit?() + } + } + .padding(.horizontal, 12) + .frame(height: 44) + .background(Color.fieldBackground) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke( + isFocused ? Color.amber : Color.cardBorder, + lineWidth: isFocused ? 1.5 : 1 + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .animation(.spring(response: 0.2, dampingFraction: 0.7), value: isFocused) + } + } +} + +// MARK: - Local color helpers + +private extension Color { + static let appBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.09, green: 0.09, blue: 0.11, alpha: 1) : UIColor.systemGroupedBackground }) + static let cardBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1) : UIColor.secondarySystemGroupedBackground }) + static let cardBorder = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(white: 0.25, alpha: 1) : UIColor.separator }) + static let fieldBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.11, green: 0.11, blue: 0.13, alpha: 1) : UIColor.secondarySystemBackground }) + static let ctaText = Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1)) // zinc-900 + static let errorBackground = Color(red: 0.40, green: 0.05, blue: 0.05).opacity(0.40) + static let errorBorder = Color(red: 0.70, green: 0.20, blue: 0.20).opacity(0.60) + static let errorText = Color(red: 0.98, green: 0.60, blue: 0.60) +} diff --git a/ios/LibNovelV2/Views/BookDetail/BookDetailView.swift b/ios/LibNovelV2/Views/BookDetail/BookDetailView.swift new file mode 100644 index 0000000..b079fa6 --- /dev/null +++ b/ios/LibNovelV2/Views/BookDetail/BookDetailView.swift @@ -0,0 +1,671 @@ +import SwiftUI + +// MARK: - BookDetailView +// Displays book hero (blurred cover bg + cover art + title), meta stats, +// expandable summary, CTA buttons, chapters row (→ sheet), and bottom save toggle. +// Matches the web UI at ui/src/routes/books/[slug]/+page.svelte. + +struct BookDetailView: View { + let slug: String + + @State private var vm: BookDetailViewModel + @State private var showChapters = false + @State private var summaryExpanded = false + @EnvironmentObject private var networkMonitor: NetworkMonitor + @EnvironmentObject private var authStore: AuthStore + + init(slug: String) { + self.slug = slug + _vm = State(initialValue: BookDetailViewModel(slug: slug)) + } + + var body: some View { + VStack(spacing: 0) { + OfflineBanner() + + Group { + if vm.isLoading && vm.book == nil { + loadingState + } else if let book = vm.book { + content(book: book) + } else if vm.error != nil { + errorState + } + } + } + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .navigationBarTitleDisplayMode(.inline) + .appNavigationDestination() + .toolbar { toolbarContent } + .task { + guard networkMonitor.isConnected else { return } + await vm.load() + } + .errorAlert($vm.error) + .sheet(isPresented: $showChapters) { + BookChaptersSheet( + slug: slug, + chapters: vm.chapters, + lastChapter: vm.lastChapter + ) + } + } + + // MARK: - Main content + + private func content(book: Book) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + heroSection(book: book) + statsRow(book: book) + Divider() + .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) + .padding(.horizontal, 16) + summarySection(book: book) + Divider() + .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) + .padding(.horizontal, 16) + ctaButtons + Divider() + .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) + chaptersRow + Divider() + .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) + + Color.clear.frame(height: 120) + } + } + .ignoresSafeArea(edges: .top) + } + + // MARK: - Hero + + private func heroSection(book: Book) -> some View { + ZStack(alignment: .bottom) { + // Blurred cover background + AsyncCoverImage(url: book.cover, isBackground: true) + .frame(maxWidth: .infinity) + .frame(height: 340) + .blur(radius: 28) + .clipped() + .overlay( + LinearGradient( + colors: [ + Color.black.opacity(0.2), + Color.black.opacity(0.72), + ], + startPoint: .top, + endPoint: .bottom + ) + ) + + VStack(spacing: 16) { + // Cover art + AsyncCoverImage(url: book.cover) + .frame(width: 130, height: 188) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10) + .shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3) + + // Title + author + VStack(spacing: 5) { + Text(book.title) + .font(.title3.bold()) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .lineLimit(3) + .padding(.horizontal, 24) + + if !book.author.isEmpty { + Text(book.author) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.7)) + } + } + + // Status badge + genre chips + VStack(spacing: 8) { + if !book.status.isEmpty { + BookStatusBadge(status: book.status) + } + if !book.genres.isEmpty { + HStack(spacing: 6) { + ForEach(book.genres.prefix(3), id: \.self) { genre in + Text(genre) + .font(.caption2.bold()) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.ultraThinMaterial, in: Capsule()) + .foregroundStyle(.white.opacity(0.9)) + } + } + } + } + + // "Not in library" badge + if !vm.inLib { + HStack(spacing: 6) { + Image(systemName: "icloud.and.arrow.down") + .font(.caption2) + Text("Not in library") + .font(.caption2) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(.regularMaterial, in: Capsule()) + } + } + .padding(.horizontal) + .padding(.bottom, 28) + } + .frame(minHeight: 340) + } + + // MARK: - Stats row + + private func statsRow(book: Book) -> some View { + HStack(spacing: 0) { + BookMetaStat( + value: "\(vm.chapters.isEmpty ? book.totalChapters : vm.chapters.count)", + label: "Chapters", + icon: "doc.text" + ) + Divider().frame(height: 36) + BookMetaStat( + value: book.status.isEmpty ? "—" : book.status.capitalized, + label: "Status", + icon: "flag" + ) + if book.ranking > 0 { + Divider().frame(height: 36) + BookMetaStat(value: "#\(book.ranking)", label: "Rank", icon: "chart.bar.fill") + } + } + .padding(.vertical, 16) + .frame(maxWidth: .infinity) + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + } + + // MARK: - Summary + + private func summarySection(book: Book) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("About") + .font(.headline) + .padding(.horizontal, 16) + + if book.summary.isEmpty { + Text("No description available.") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.horizontal, 16) + } else { + Text(book.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(summaryExpanded ? nil : 4) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: summaryExpanded) + .padding(.horizontal, 16) + + if book.summary.count > 200 { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + summaryExpanded.toggle() + } + } label: { + Text(summaryExpanded ? "Less" : "More") + .font(.caption.bold()) + .foregroundStyle(Color.amber) + } + .buttonStyle(.plain) + .frame(minWidth: 44, minHeight: 44) + .padding(.horizontal, 16) + } + } + } + .padding(.vertical, 16) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - CTA buttons + + private var ctaButtons: some View { + HStack(spacing: 10) { + if let last = vm.lastChapter, last > 0 { + // Continue reading + NavigationLink(value: NavDestination.chapter(slug, last)) { + Label("Continue Ch.\(last)", systemImage: "play.fill") + .font(.subheadline.bold()) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Color.amber) + .foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1))) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + }) + + // Start from ch.1 + NavigationLink(value: NavDestination.chapter(slug, 1)) { + Label("Ch.1", systemImage: "arrow.counterclockwise") + .font(.subheadline.bold()) + .frame(height: 44) + .padding(.horizontal, 16) + .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) + .foregroundStyle(.primary) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + }) + } else { + // Start reading + NavigationLink(value: NavDestination.chapter(slug, 1)) { + Label(vm.inLib ? "Start Reading" : "Preview Ch.1", systemImage: "book.fill") + .font(.subheadline.bold()) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(vm.chapters.isEmpty ? Color.amber.opacity(0.4) : Color.amber) + .foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1))) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(vm.chapters.isEmpty) + .simultaneousGesture(TapGesture().onEnded { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + }) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + } + + // MARK: - Chapters row + + private var chaptersRow: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showChapters = true + } label: { + HStack(spacing: 12) { + Image(systemName: "list.number") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.amber) + .frame(width: 28) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text("Chapters") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + + let count = vm.chapters.count + if let last = vm.lastChapter, last > 0, count > 0 { + Text("Reading Ch.\(last) of \(count)") + .font(.caption) + .foregroundStyle(.secondary) + } else if count > 0 { + Text("\(count) chapter\(count == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + } else if vm.isLoading { + Text("Loading…") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Chapters list") + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + Task { await vm.toggleSaved() } + } label: { + Image(systemName: vm.saved ? "bookmark.fill" : "bookmark") + .foregroundStyle(vm.saved ? Color.amber : .primary) + .contentTransition(.symbolEffect(.replace.downUp)) + } + .disabled(vm.isSaving) + .accessibilityLabel(vm.saved ? "Remove from library" : "Save to library") + } + } + + // MARK: - Loading / Error states + + private var loadingState: some View { + VStack { + Spacer() + ProgressView() + .tint(Color.amber) + .scaleEffect(1.4) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var errorState: some View { + VStack { + Spacer() + EmptyStateView( + icon: "wifi.slash", + title: "Couldn't load book", + message: vm.error ?? "Something went wrong.", + ctaLabel: "Retry", + ctaAction: { + Task { + guard networkMonitor.isConnected else { return } + await vm.load() + } + } + ) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - BookChaptersSheet +// Shows all chapters in groups of 100 with a searchable list and right-edge jump bar. + +struct BookChaptersSheet: View { + let slug: String + let chapters: [ChapterIndex] + let lastChapter: Int? + + @Environment(\.dismiss) private var dismiss + @State private var searchText = "" + + private var filtered: [ChapterIndex] { + guard !searchText.isEmpty else { return chapters } + let q = searchText.lowercased() + return chapters.filter { + "\($0.number)".contains(q) || $0.title.lowercased().contains(q) + } + } + + /// Chapters in blocks of 100, or a flat "Results" group when searching. + private var groups: [(label: String, chapters: [ChapterIndex])] { + guard searchText.isEmpty else { + return filtered.isEmpty ? [] : [("Results", filtered)] + } + guard !filtered.isEmpty else { return [] } + let blockSize = 100 + let minN = filtered.map(\.number).min() ?? 1 + let maxN = filtered.map(\.number).max() ?? 1 + let firstBlock = ((minN - 1) / blockSize) * blockSize + 1 + var result: [(label: String, chapters: [ChapterIndex])] = [] + var blockStart = firstBlock + while blockStart <= maxN { + let blockEnd = blockStart + blockSize - 1 + let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd } + if !slice.isEmpty { result.append(("\(blockStart)–\(blockEnd)", slice)) } + blockStart += blockSize + } + return result + } + + @State private var activeBlock: String? + + var body: some View { + NavigationStack { + ZStack(alignment: .trailing) { + List { + ForEach(groups, id: \.label) { group in + Section { + ForEach(group.chapters, id: \.number) { ch in + ChapterListRow( + chapter: ch, + slug: slug, + isCurrent: ch.number == lastChapter + ) + .id(ch.number) + } + } header: { + if searchText.isEmpty { + Text(group.label) + .font(.caption.bold()) + .foregroundStyle(.secondary) + .id("header_\(group.label)") + } + } + } + + if chapters.isEmpty { + Section { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .listRowBackground(Color.clear) + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Chapter number or title" + ) + .scrollPosition(id: $activeBlock, anchor: .top) + .appNavigationDestination() + + // Jump bar (hidden while searching) + if searchText.isEmpty && groups.count > 1 { + ChapterJumpBar( + labels: groups.map(\.label), + currentChapter: lastChapter ?? 0, + groups: groups + ) { label in + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + activeBlock = label + } + } + .padding(.trailing, 4) + } + } + .navigationTitle("Chapters (\(filtered.count))") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + .foregroundStyle(Color.amber) + } + } + .onAppear { + // Scroll to current chapter's block on open + if let block = groups.first(where: { g in + g.chapters.contains(where: { $0.number == (lastChapter ?? 0) }) + }) { + activeBlock = block.label + } + } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } +} + +// MARK: - ChapterListRow + +private struct ChapterListRow: View { + let chapter: ChapterIndex + let slug: String + let isCurrent: Bool + + private var displayTitle: String { + let pattern = #"\s*[-–]\s*\w+\s+\d{1,2}\s+\d{4}\s*$"# + let stripped = (try? NSRegularExpression(pattern: pattern))? + .stringByReplacingMatches( + in: chapter.title, + range: NSRange(chapter.title.startIndex..., in: chapter.title), + withTemplate: "" + ).trimmingCharacters(in: .whitespaces) ?? chapter.title + if stripped.isEmpty || stripped == "Chapter \(chapter.number)" { + return "Chapter \(chapter.number)" + } + return stripped + } + + var body: some View { + NavigationLink(value: NavDestination.chapter(slug, chapter.number)) { + HStack(spacing: 14) { + // Number badge + ZStack { + Circle() + .fill(isCurrent ? Color.amber : Color(.systemGray5)) + .frame(width: 40, height: 40) + Text("\(chapter.number)") + .font(.caption.bold().monospacedDigit()) + .foregroundStyle(isCurrent ? .white : .secondary) + .minimumScaleFactor(0.6) + .frame(width: 40, height: 40) + } + + VStack(alignment: .leading, spacing: 3) { + Text(displayTitle) + .font(.subheadline.weight(isCurrent ? .semibold : .regular)) + .foregroundStyle(isCurrent ? Color.amber : .primary) + .lineLimit(1) + + if isCurrent { + Label("Reading", systemImage: "bookmark.fill") + .font(.caption2) + .foregroundStyle(Color.amber) + } else if !chapter.dateLabel.isEmpty { + Text(chapter.dateLabel) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + Spacer(minLength: 4) + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear) + .listRowSeparatorTint(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) + } +} + +// MARK: - ChapterJumpBar + +private struct ChapterJumpBar: View { + let labels: [String] + let currentChapter: Int + let groups: [(label: String, chapters: [ChapterIndex])] + let onSelect: (String) -> Void + + private func shortLabel(_ full: String) -> String { + full.components(separatedBy: "–").first ?? full + } + + private var currentBlock: String? { + groups.first(where: { g in g.chapters.contains(where: { $0.number == currentChapter }) })?.label + } + + var body: some View { + VStack(spacing: 0) { + ForEach(labels, id: \.self) { label in + let isCurrent = label == currentBlock + Text(shortLabel(label)) + .font(.system(size: 10, weight: isCurrent ? .bold : .regular)) + .foregroundStyle(isCurrent ? Color.amber : Color.secondary) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + .onTapGesture { onSelect(label) } + } + } + .padding(.vertical, 6) + .background( + Capsule() + .fill(.ultraThinMaterial) + .shadow(color: .black.opacity(0.15), radius: 4) + ) + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .local) + .onChanged { value in + let itemHeight: CGFloat = 28 + let index = Int(value.location.y / itemHeight) + let clamped = max(0, min(labels.count - 1, index)) + onSelect(labels[clamped]) + } + ) + } +} + +// MARK: - BookStatusBadge + +private struct BookStatusBadge: View { + let status: String + + private var color: Color { + switch status.lowercased() { + case "ongoing", "active": return .green + case "completed": return .blue + case "hiatus": return .orange + default: return .secondary + } + } + + var body: some View { + HStack(spacing: 4) { + Circle().fill(color).frame(width: 6, height: 6) + Text(status.capitalized) + .font(.caption.weight(.medium)) + .foregroundStyle(color) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(color.opacity(0.12), in: Capsule()) + } +} + +// MARK: - BookMetaStat + +private struct BookMetaStat: View { + let value: String + let label: String + let icon: String + + var body: some View { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.caption) + .foregroundStyle(Color.amber) + Text(value) + .font(.subheadline.bold()) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } +} diff --git a/ios/LibNovelV2/Views/Browse/BrowseCategoryView.swift b/ios/LibNovelV2/Views/Browse/BrowseCategoryView.swift new file mode 100644 index 0000000..c6b5a28 --- /dev/null +++ b/ios/LibNovelV2/Views/Browse/BrowseCategoryView.swift @@ -0,0 +1,446 @@ +import SwiftUI + +// MARK: - BrowseCategoryView +// Full paginated grid for "See All" / genre deep-dives. +// Supports browse (infinite scroll) and rank (flat list) modes. +// Sort/genre/status can be adjusted via the filters sheet. + +struct BrowseCategoryView: View { + let sort: String + let genre: String + let status: String + let title: String + + @State private var vm = BrowseViewModel() + @State private var showFilters = false + @EnvironmentObject private var networkMonitor: NetworkMonitor + + init(sort: String, genre: String, status: String, title: String) { + self.sort = sort + self.genre = genre + self.status = status + self.title = title + } + + private var isRankMode: Bool { sort == "rank" } + + var body: some View { + Group { + if vm.isLoading && vm.novels.isEmpty { + loadingState + } else if let err = vm.error, vm.novels.isEmpty { + errorState(message: err) + } else if vm.novels.isEmpty && !vm.isLoading { + emptyState + } else if isRankMode { + rankList + } else { + novelGrid + } + } + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.large) + .appNavigationDestination() + .toolbar { toolbarContent } + .task { + guard networkMonitor.isConnected else { return } + vm.sort = sort + vm.genre = genre + vm.status = status + if vm.novels.isEmpty { + if isRankMode { + await vm.loadRanking() + } else { + await vm.loadFirstPage() + } + } + } + .onChange(of: vm.sort) { _, _ in + Task { await refreshForFilters() } + } + .onChange(of: vm.genre) { _, _ in + Task { await refreshForFilters() } + } + .onChange(of: vm.status) { _, _ in + Task { await refreshForFilters() } + } + .sheet(isPresented: $showFilters) { + BrowseFiltersSheet(vm: vm) + } + .errorAlert($vm.error) + } + + // MARK: - Grid view + + private let columns = [ + GridItem(.flexible(), spacing: 14), + GridItem(.flexible(), spacing: 14) + ] + + private var novelGrid: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 14) { + ForEach(vm.novels) { novel in + NavigationLink(value: NavDestination.book(novel.slug)) { + BrowseCategoryCard(novel: novel) + } + .buttonStyle(.plain) + .onAppear { + if novel.id == vm.novels.last?.id && vm.hasNext { + Task { await vm.loadNextPage() } + } + } + } + } + .padding(.horizontal, 16) + .padding(.top, 12) + + // Load-more indicator + if vm.isLoadingMore { + ProgressView() + .padding(.vertical, 24) + .tint(Color.amber) + } else if !vm.hasNext && !vm.novels.isEmpty { + Text("All novels loaded") + .font(.caption) + .foregroundStyle(.quaternary) + .padding(.vertical, 24) + } + + Color.clear.frame(height: 120) + } + .refreshable { await vm.loadFirstPage() } + } + + // MARK: - Rank list view + + private var rankList: some View { + List { + ForEach(vm.novels) { novel in + NavigationLink(value: NavDestination.book(novel.slug)) { + RankListRow(novel: novel) + } + .listRowBackground(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) + .listRowSeparatorTint(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .refreshable { await vm.loadRanking() } + } + + // MARK: - Loading / error / empty + + private var loadingState: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 14) { + ForEach(0..<10, id: \.self) { _ in + BrowseCategoryCardSkeleton() + } + } + .padding(.horizontal, 16) + .padding(.top, 12) + } + } + + private func errorState(message: String) -> some View { + VStack(spacing: 16) { + Spacer() + EmptyStateView( + icon: "wifi.slash", + title: "Couldn't load", + message: message, + ctaLabel: "Retry", + ctaAction: { + Task { + if isRankMode { await vm.loadRanking() } + else { await vm.loadFirstPage() } + } + } + ) + Spacer() + } + } + + private var emptyState: some View { + VStack { + Spacer() + EmptyStateView( + icon: "books.vertical", + title: "No novels found", + message: "Try different filters.", + ctaLabel: "Change Filters", + ctaAction: { showFilters = true } + ) + Spacer() + } + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showFilters = true + } label: { + Image(systemName: "slider.horizontal.3") + .foregroundStyle(Color.amber) + } + .accessibilityLabel("Filter novels") + } + } + + // MARK: - Filter change + + private func refreshForFilters() async { + if vm.sort == "rank" { + await vm.loadRanking() + } else { + await vm.loadFirstPage() + } + } +} + +// MARK: - BrowseCategoryCard + +struct BrowseCategoryCard: View { + let novel: BrowseNovel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .topLeading) { + AsyncCoverImage(url: novel.cover) + .frame(maxWidth: .infinity) + .aspectRatio(2/3, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .bookCoverZoomSource(slug: novel.slug) + + if !novel.rank.isEmpty { + Text(novel.rank) + .font(.caption2.bold()) + .foregroundStyle(Color.amber) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.ultraThinMaterial, in: Capsule()) + .padding(6) + } + } + + VStack(alignment: .leading, spacing: 3) { + Text(novel.title) + .font(.subheadline.bold()) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + if !novel.author.isEmpty { + Text(novel.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if !novel.chapters.isEmpty { + Text(novel.chapters) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2) + } +} + +// MARK: - BrowseCategoryCardSkeleton + +private struct BrowseCategoryCardSkeleton: View { + var body: some View { + VStack(alignment: .leading, spacing: 0) { + RoundedRectangle(cornerRadius: 10) + .fill(Color(uiColor: UIColor(red: 0.18, green: 0.18, blue: 0.20, alpha: 1))) + .aspectRatio(2/3, contentMode: .fit) + + VStack(alignment: .leading, spacing: 6) { + RoundedRectangle(cornerRadius: 4) + .fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1))) + .frame(height: 14) + RoundedRectangle(cornerRadius: 4) + .fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1))) + .frame(width: 80, height: 11) + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + } + .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} + +// MARK: - RankListRow + +private struct RankListRow: View { + let novel: BrowseNovel + + var body: some View { + HStack(spacing: 12) { + // Rank number + Text(novel.rank.isEmpty ? "–" : novel.rank) + .font(.subheadline.bold()) + .foregroundStyle(Color.amber) + .frame(width: 36, alignment: .trailing) + + // Cover thumbnail + AsyncCoverImage(url: novel.cover) + .frame(width: 44, height: 62) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + + // Title + meta + VStack(alignment: .leading, spacing: 3) { + Text(novel.title) + .font(.subheadline.bold()) + .lineLimit(2) + .foregroundStyle(.primary) + + if !novel.author.isEmpty { + Text(novel.author) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + HStack(spacing: 6) { + if !novel.status.isEmpty { + TagChip(label: novel.status.capitalized) + } + if !novel.rating.isEmpty { + TagChip(label: "★ \(novel.rating)") + } + } + } + + Spacer() + } + .padding(.vertical, 6) + .frame(minHeight: 44) + } +} + +// MARK: - BrowseFiltersSheet + +struct BrowseFiltersSheet: View { + var vm: BrowseViewModel + @Environment(\.dismiss) private var dismiss + + private let sortOptions: [(value: String, label: String)] = [ + ("popular", "Popular"), + ("new", "New"), + ("update", "Updated"), + ("rank", "Ranking"), + ] + private let genreOptions: [(value: String, label: String)] = [ + ("all", "All Genres"), + ("action", "Action"), + ("adventure", "Adventure"), + ("comedy", "Comedy"), + ("drama", "Drama"), + ("fantasy", "Fantasy"), + ("harem", "Harem"), + ("historical", "Historical"), + ("horror", "Horror"), + ("isekai", "Isekai"), + ("martial-arts", "Martial Arts"), + ("mystery", "Mystery"), + ("psychological", "Psychological"), + ("romance", "Romance"), + ("sci-fi", "Sci-Fi"), + ("system", "System"), + ("xianxia", "Xianxia"), + ] + private let statusOptions: [(value: String, label: String)] = [ + ("all", "All"), + ("ongoing", "Ongoing"), + ("completed", "Completed"), + ] + + var body: some View { + NavigationStack { + Form { + Section("Sort") { + ForEach(sortOptions, id: \.value) { opt in + filterRow(label: opt.label, isSelected: vm.sort == opt.value) { + vm.sort = opt.value + dismiss() + } + } + } + + Section("Genre") { + ForEach(genreOptions, id: \.value) { opt in + filterRow(label: opt.label, isSelected: vm.genre == opt.value) { + vm.genre = opt.value + dismiss() + } + } + } + .disabled(vm.sort == "rank") + + Section("Status") { + ForEach(statusOptions, id: \.value) { opt in + filterRow(label: opt.label, isSelected: vm.status == opt.value) { + vm.status = opt.value + dismiss() + } + } + } + .disabled(vm.sort == "rank") + + if vm.sort == "rank" { + Section { + Text("Genre & status filters apply to Browse only") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Filters") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + .foregroundStyle(Color.amber) + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + + @ViewBuilder + private func filterRow(label: String, isSelected: Bool, action: @escaping () -> Void) -> some View { + HStack { + Text(label) + Spacer() + if isSelected { + Image(systemName: "checkmark") + .foregroundStyle(Color.amber) + .fontWeight(.semibold) + } + } + .contentShape(Rectangle()) + .onTapGesture { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + action() + } + .frame(minHeight: 44) + } +} diff --git a/ios/LibNovelV2/Views/Browse/BrowseView.swift b/ios/LibNovelV2/Views/Browse/BrowseView.swift new file mode 100644 index 0000000..5383ff2 --- /dev/null +++ b/ios/LibNovelV2/Views/Browse/BrowseView.swift @@ -0,0 +1,411 @@ +import SwiftUI + +// MARK: - BrowseView +// "Discover" tab: curated horizontal shelves (Trending, New, Updated, Ranking) +// plus a genre picker sheet. Mirrors the web UI's serendipitous browse experience. + +struct BrowseView: View { + @State private var vm = BrowseViewModel() + @State private var showGenreSheet = false + @EnvironmentObject private var networkMonitor: NetworkMonitor + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + OfflineBanner() + + Group { + if vm.isLoading && vm.trending.isEmpty { + loadingState + } else if let err = vm.error, vm.trending.isEmpty { + errorState(message: err) + } else { + shelvesContent + } + } + } + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .navigationTitle("Discover") + .navigationBarTitleDisplayMode(.large) + .appNavigationDestination() + .task { + guard networkMonitor.isConnected else { return } + if vm.trending.isEmpty { await vm.loadShelves() } + } + .refreshable { await vm.loadShelves() } + .errorAlert($vm.error) + .sheet(isPresented: $showGenreSheet) { + GenrePickerSheet() + } + } + } + + // MARK: - Shelves content + + private var shelvesContent: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 32) { + + // Trending Now + if !vm.trending.isEmpty { + BrowseShelf( + title: "Trending Now", + novels: vm.trending, + destination: NavDestination.browseCategory( + sort: "popular", genre: "all", status: "all", title: "Trending Now" + ) + ) + } + + // New Releases + if !vm.newReleases.isEmpty { + BrowseShelf( + title: "New Releases", + novels: vm.newReleases, + destination: NavDestination.browseCategory( + sort: "new", genre: "all", status: "all", title: "New Releases" + ) + ) + } + + // Recently Updated + if !vm.recentlyUpdated.isEmpty { + BrowseShelf( + title: "Recently Updated", + novels: vm.recentlyUpdated, + destination: NavDestination.browseCategory( + sort: "update", genre: "all", status: "all", title: "Recently Updated" + ) + ) + } + + // Rankings (list-style shelf) + if !vm.ranking.isEmpty { + BrowseShelf( + title: "Rankings", + novels: vm.ranking, + destination: NavDestination.browseCategory( + sort: "rank", genre: "all", status: "all", title: "Rankings" + ), + showRank: true + ) + } + + // Browse by Genre + CategoriesRow { showGenreSheet = true } + .padding(.horizontal, 16) + + Color.clear.frame(height: 120) + } + .padding(.top, 8) + } + } + + // MARK: - Loading / error states + + private var loadingState: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 32) { + ForEach(0..<3, id: \.self) { _ in + BrowseShelfSkeleton() + } + } + .padding(.top, 8) + } + } + + private func errorState(message: String) -> some View { + VStack(spacing: 16) { + Spacer() + EmptyStateView( + icon: "wifi.slash", + title: "Couldn't load", + message: message, + ctaLabel: "Retry", + ctaAction: { Task { await vm.loadShelves() } } + ) + Spacer() + } + } +} + +// MARK: - BrowseShelf +// Amber-accented header + horizontal card scroll + "See All" link. + +struct BrowseShelf: View { + let title: String + let novels: [BrowseNovel] + let destination: NavDestination + var showRank: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header row + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(Color.amber) + .frame(width: 3, height: 18) + Text(title) + .font(.title3.bold()) + Spacer() + NavigationLink(value: destination) { + HStack(spacing: 4) { + Text("See All") + .font(.subheadline) + Image(systemName: "chevron.right") + .font(.caption.bold()) + } + .foregroundStyle(Color.amber) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + + // Horizontal scroll + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 12) { + ForEach(novels) { novel in + NavigationLink(value: NavDestination.book(novel.slug)) { + BrowseShelfCard(novel: novel, showRank: showRank) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 4) + } + } + } +} + +// MARK: - BrowseShelfCard + +struct BrowseShelfCard: View { + let novel: BrowseNovel + var showRank: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .topLeading) { + AsyncCoverImage(url: novel.cover) + .frame(width: 120, height: 173) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .bookCoverZoomSource(slug: novel.slug) + + if showRank && !novel.rank.isEmpty { + Text(novel.rank) + .font(.caption2.bold()) + .foregroundStyle(Color.amber) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.ultraThinMaterial, in: Capsule()) + .padding(6) + } else if !novel.rank.isEmpty { + Text(novel.rank) + .font(.caption2.bold()) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.ultraThinMaterial, in: Capsule()) + .padding(6) + } + } + + VStack(alignment: .leading, spacing: 3) { + Text(novel.title) + .font(.caption.bold()) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(width: 120, alignment: .leading) + + if !novel.author.isEmpty { + Text(novel.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: 120, alignment: .leading) + } else if !novel.chapters.isEmpty { + Text(novel.chapters) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: 120, alignment: .leading) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 8) + } + .frame(width: 132) + .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2) + } +} + +// MARK: - BrowseShelfSkeleton + +struct BrowseShelfSkeleton: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header skeleton + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.amber.opacity(0.3)) + .frame(width: 3, height: 18) + RoundedRectangle(cornerRadius: 6) + .fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1))) + .frame(width: 140, height: 20) + Spacer() + } + .padding(.horizontal, 16) + + // Cards skeleton + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(0..<5, id: \.self) { _ in + RoundedRectangle(cornerRadius: 14) + .fill(Color(uiColor: UIColor(red: 0.18, green: 0.18, blue: 0.20, alpha: 1))) + .frame(width: 132, height: 220) + } + } + .padding(.horizontal, 16) + } + } + } +} + +// MARK: - CategoriesRow + +struct CategoriesRow: View { + let onTap: () -> Void + + var body: some View { + Button(action: { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + onTap() + }) { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.amber.opacity(0.15)) + .frame(width: 44, height: 44) + Image(systemName: "square.grid.2x2") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(Color.amber) + } + + VStack(alignment: .leading, spacing: 2) { + Text("Browse by Genre") + .font(.body.weight(.semibold)) + .foregroundStyle(.primary) + Text("Action, Fantasy, Romance & more") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(14) + .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel("Browse by Genre") + } +} + +// MARK: - GenrePickerSheet + +struct GenrePickerSheet: View { + @Environment(\.dismiss) private var dismiss + + private let genres: [(label: String, value: String, icon: String)] = [ + ("All Novels", "all", "books.vertical.fill"), + ("Action", "action", "bolt.fill"), + ("Adventure", "adventure", "map.fill"), + ("Comedy", "comedy", "face.smiling.fill"), + ("Drama", "drama", "theatermasks.fill"), + ("Fantasy", "fantasy", "wand.and.stars"), + ("Harem", "harem", "person.3.fill"), + ("Historical", "historical", "building.columns.fill"), + ("Horror", "horror", "moon.fill"), + ("Isekai", "isekai", "globe.americas.fill"), + ("Martial Arts", "martial-arts", "figure.martial.arts"), + ("Mystery", "mystery", "magnifyingglass"), + ("Psychological","psychological","brain.head.profile"), + ("Romance", "romance", "heart.fill"), + ("Sci-Fi", "sci-fi", "sparkles"), + ("System", "system", "cpu"), + ("Xianxia", "xianxia", "leaf.fill"), + ] + + private let columns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ] + + var body: some View { + NavigationStack { + ScrollView { + LazyVGrid(columns: columns, spacing: 12) { + ForEach(genres, id: \.value) { item in + NavigationLink(value: NavDestination.browseCategory( + sort: "popular", + genre: item.value, + status: "all", + title: item.label + )) { + GenreTile(label: item.label, icon: item.icon) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded { dismiss() }) + } + } + .padding(16) + .padding(.bottom, 20) + } + .navigationTitle("Genres") + .navigationBarTitleDisplayMode(.large) + .appNavigationDestination() + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + .foregroundStyle(Color.amber) + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(20) + } +} + +// MARK: - GenreTile + +private struct GenreTile: View { + let label: String + let icon: String + + var body: some View { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Color.amber) + .frame(width: 24) + Text(label) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .frame(minHeight: 44) + } +} diff --git a/ios/LibNovelV2/Views/ChapterReader/ChapterReaderView.swift b/ios/LibNovelV2/Views/ChapterReader/ChapterReaderView.swift new file mode 100644 index 0000000..5ee68e0 --- /dev/null +++ b/ios/LibNovelV2/Views/ChapterReader/ChapterReaderView.swift @@ -0,0 +1,1232 @@ +import SwiftUI +import CoreText + +// MARK: - Chapter Reader View + +struct ChapterReaderView: View { + let slug: String + let chapterNumber: Int + + @State private var currentChapter: Int + @State private var vm: ChapterReaderViewModel + @State private var readerSettings = ReaderSettingsStore() + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var authStore: AuthStore + + @State private var chromeVisible = true + @State private var showSettingsPanel = false + @State private var showToCSheet = false + + @Environment(\.dismiss) private var dismiss + + init(slug: String, chapterNumber: Int) { + self.slug = slug + self.chapterNumber = chapterNumber + _currentChapter = State(initialValue: chapterNumber) + _vm = State(initialValue: ChapterReaderViewModel(slug: slug, chapter: chapterNumber)) + } + + var body: some View { + ZStack { + // Full-bleed background + readerSettings.settings.theme.backgroundColor + .ignoresSafeArea() + + if vm.isLoading { + ProgressView() + .tint(readerSettings.settings.theme.textColor) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let content = vm.content { + if readerSettings.settings.scrollMode { + ScrollReaderContent( + content: content, + readerSettings: readerSettings, + chromeVisible: $chromeVisible, + onNavigateChapter: navigateToChapter + ) + } else { + PaginatedReaderContent( + content: content, + readerSettings: readerSettings, + chromeVisible: $chromeVisible, + onNavigateChapter: navigateToChapter + ) + } + } else if let errMsg = vm.error { + readerErrorView(errMsg) + } + + // Chrome overlay + if chromeVisible { + VStack(spacing: 0) { + topChrome + Spacer() + if let content = vm.content { + bottomChrome(content: content) + } + } + .transition(.opacity.animation(.easeInOut(duration: 0.22))) + .ignoresSafeArea(edges: .top) + } + } + .ignoresSafeArea(edges: .all) + .navigationBarHidden(true) + .toolbar(.hidden, for: .tabBar) + .preferredColorScheme(readerSettings.settings.theme.colorScheme) + .hideMiniPlayer() + .task(id: currentChapter) { await vm.load() } + .sheet(isPresented: $showSettingsPanel) { + ReaderSettingsPanel(store: readerSettings, isPresented: $showSettingsPanel) + .presentationDetents([.height(460)]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(24) + .presentationBackground(.regularMaterial) + } + .sheet(isPresented: $showToCSheet) { + if let content = vm.content { + ChaptersListSheet( + chapters: content.chapters, + currentChapter: currentChapter, + onChapterSelect: { selected in + showToCSheet = false + navigateToChapter(selected) + } + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + } + .onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in + guard let next = note.userInfo?["next"] as? Int, + let autoNext = note.userInfo?["autoNext"] as? Bool, + autoNext, currentChapter == audioPlayer.chapter else { return } + navigateToChapter(next) + } + .onReceive(NotificationCenter.default.publisher(for: .skipToNextChapter)) { note in + guard let next = note.userInfo?["next"] as? Int, + currentChapter == audioPlayer.chapter else { return } + navigateToChapter(next) + } + .onReceive(NotificationCenter.default.publisher(for: .skipToPrevChapter)) { note in + guard let prev = note.userInfo?["prev"] as? Int, + currentChapter == audioPlayer.chapter else { return } + navigateToChapter(prev) + } + } + + // MARK: - Top chrome + + private var topChrome: some View { + ZStack(alignment: .bottom) { + Rectangle() + .fill(.ultraThinMaterial) + .colorScheme(readerSettings.settings.theme.colorScheme ?? .light) + .ignoresSafeArea(edges: .top) + + VStack(spacing: 0) { + HStack(spacing: 0) { + // Back + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(readerSettings.settings.theme.textColor) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .accessibilityLabel("Back") + + Spacer() + + // Chapter title + if let content = vm.content { + Text(content.chapter.title.strippingTrailingDate()) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85)) + .lineLimit(1) + .frame(maxWidth: 200) + } + + Spacer() + + // ToC + Aa + HStack(spacing: 0) { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showToCSheet = true + } label: { + Image(systemName: "list.bullet") + .font(.system(size: 16, weight: .regular)) + .foregroundStyle(readerSettings.settings.theme.textColor) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .accessibilityLabel("Table of Contents") + + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + showSettingsPanel.toggle() + } + } label: { + Text("Aa") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(readerSettings.settings.theme.textColor) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .accessibilityLabel("Reader Settings") + } + } + .padding(.horizontal, 4) + .frame(height: 44) + + // Progress bar + if let content = vm.content { + ChapterProgressBar( + currentChapter: content.chapter.number, + totalChapters: content.chapters.last?.number ?? content.chapter.number, + color: accentColor + ) + } + } + } + .fixedSize(horizontal: false, vertical: true) + } + + // MARK: - Bottom chrome + + private func bottomChrome(content: ChapterResponse) -> some View { + HStack(alignment: .center, spacing: 12) { + + // Prev chapter + if let prev = content.prev { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + navigateToChapter(prev) + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 12, weight: .bold)) + Text("Ch.\(prev)") + .font(.caption.weight(.semibold)) + } + .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7)) + .frame(minWidth: 64) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Previous chapter \(prev)") + } else { + Color.clear.frame(width: 64, height: 40) + } + + Spacer(minLength: 0) + + // Download + DownloadAudioButton( + slug: slug, + chapter: currentChapter, + voice: audioPlayer.voice, + theme: readerSettings.settings.theme + ) + + // Listen pill + ListenButton( + audioPlayer: audioPlayer, + vm: vm, + authStore: authStore, + theme: readerSettings.settings.theme + ) + + Spacer(minLength: 0) + + // Next chapter + if let next = content.next { + Button { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + navigateToChapter(next) + } label: { + HStack(spacing: 4) { + Text("Ch.\(next)") + .font(.caption.weight(.semibold)) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .bold)) + } + .foregroundStyle(.white) + .frame(minWidth: 64) + .padding(.vertical, 10) + .background(Capsule().fill(accentColor)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("Next chapter \(next)") + } else { + Color.clear.frame(width: 64, height: 40) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + Rectangle() + .fill(.ultraThinMaterial) + .colorScheme(readerSettings.settings.theme.colorScheme ?? .light) + .ignoresSafeArea(edges: .bottom) + ) + } + + // MARK: - Helpers + + private var accentColor: Color { + readerSettings.settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) + : .amber + } + + private func readerErrorView(_ msg: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.orange) + Text(msg) + .multilineTextAlignment(.center) + .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7)) + .padding(.horizontal) + Button("Retry") { Task { await vm.load() } } + .buttonStyle(.borderedProminent) + .tint(.amber) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func navigateToChapter(_ chapter: Int) { + vm.switchChapter(to: chapter) + currentChapter = chapter + } +} + +// MARK: - Paginated reader content + +private struct PaginatedReaderContent: View { + let content: ChapterResponse + let readerSettings: ReaderSettingsStore + @Binding var chromeVisible: Bool + let onNavigateChapter: (Int) -> Void + + @State private var pages: [AttributedString] = [] + @State private var currentPage: Int = 0 + @State private var geometrySize: CGSize = .zero + + private let topReserve: CGFloat = 80 + private let bottomReserve: CGFloat = 64 + + var body: some View { + GeometryReader { geo in + let size = geo.size + TabView(selection: $currentPage) { + ChapterTitlePage(content: content, readerSettings: readerSettings) + .tag(-1) + .onTapGesture { toggleChrome() } + + ForEach(Array(pages.enumerated()), id: \.offset) { idx, page in + ReaderPage( + text: page, + readerSettings: readerSettings, + pageNumber: idx + 1, + totalPages: pages.count + ) + .tag(idx) + .onTapGesture { toggleChrome() } + } + + ChapterEndPage( + content: content, + readerSettings: readerSettings, + onNavigateChapter: onNavigateChapter + ) + .tag(pages.count) + .onTapGesture { toggleChrome() } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .onAppear { + if geometrySize != size { + geometrySize = size + repaginate(size: size) + } + } + .onChange(of: size) { _, newSize in + geometrySize = newSize + repaginate(size: newSize) + } + .onChange(of: readerSettings.settings) { _, _ in + repaginate(size: geometrySize) + } + .onChange(of: content.chapter.number) { _, _ in + currentPage = -1 + repaginate(size: geometrySize) + } + } + .ignoresSafeArea() + .onAppear { currentPage = -1 } + .simultaneousGesture( + DragGesture(minimumDistance: 40, coordinateSpace: .global) + .onEnded { value in + let isHorizontal = abs(value.translation.width) > abs(value.translation.height) * 1.5 + guard isHorizontal else { return } + if value.translation.width > 0, currentPage == -1, let prev = content.prev { + onNavigateChapter(prev) + } else if value.translation.width < 0, currentPage == pages.count, let next = content.next { + onNavigateChapter(next) + } + } + ) + } + + private func toggleChrome() { + withAnimation(.easeInOut(duration: 0.22)) { chromeVisible.toggle() } + } + + private func repaginate(size: CGSize) { + guard size.width > 0, size.height > 0 else { return } + let settings = readerSettings.settings + let hPad: CGFloat = 28 + let textWidth = size.width - hPad * 2 + let textHeight = size.height - topReserve - bottomReserve + + let attributed = HTMLParser.toAttributedString( + html: content.html, + fontSize: settings.fontSize, + lineSpacing: settings.lineSpacing, + fontName: settings.font.fontName, + textColor: settings.theme.textColor + ) + pages = TextPaginator.paginate( + attributed: attributed, + width: textWidth, + height: textHeight, + fontSize: settings.fontSize + ) + if currentPage > pages.count - 1 { + currentPage = max(0, pages.count - 1) + } + } +} + +// MARK: - Scroll mode reader content + +private struct ScrollReaderContent: View { + let content: ChapterResponse + let readerSettings: ReaderSettingsStore + @Binding var chromeVisible: Bool + let onNavigateChapter: (Int) -> Void + + var body: some View { + let settings = readerSettings.settings + let hPad: CGFloat = 24 + let accent: Color = settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + // Chapter header + VStack(alignment: .leading, spacing: 10) { + Text(content.book.title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(settings.theme.textColor.opacity(0.45)) + .textCase(.uppercase) + .tracking(1.2) + Rectangle() + .fill(accent.opacity(0.6)) + .frame(width: 36, height: 2) + Text(content.chapter.title.strippingTrailingDate()) + .font(.system(size: 22, weight: .bold, design: .serif)) + .foregroundStyle(settings.theme.textColor) + if !content.chapter.dateLabel.isEmpty { + Text(content.chapter.dateLabel) + .font(.caption) + .foregroundStyle(settings.theme.textColor.opacity(0.4)) + } + } + .padding(.horizontal, hPad) + .padding(.top, 20) + .padding(.bottom, 20) + + // Body + let attributed = HTMLParser.toAttributedString( + html: content.html, + fontSize: settings.fontSize, + lineSpacing: settings.lineSpacing, + fontName: settings.font.fontName, + textColor: settings.theme.textColor + ) + Text(attributed) + .padding(.horizontal, hPad) + + // Footer + VStack(spacing: 16) { + Divider().padding(.horizontal, hPad) + if let next = content.next { + Button { onNavigateChapter(next) } label: { + HStack { + Text("Next Chapter") + .fontWeight(.semibold) + Image(systemName: "arrow.right") + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(Capsule().fill(accent)) + } + .buttonStyle(.plain) + .padding(.horizontal, hPad) + } + } + .padding(.vertical, 24) + .padding(.bottom, 80) + } + } + .safeAreaInset(edge: .top) { Color.clear.frame(height: 52) } + .background(settings.theme.backgroundColor) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.easeInOut(duration: 0.22)) { chromeVisible.toggle() } + } + } +} + +// MARK: - Individual reader page (paginated mode) + +private struct ReaderPage: View { + let text: AttributedString + let readerSettings: ReaderSettingsStore + let pageNumber: Int + let totalPages: Int + + var body: some View { + let settings = readerSettings.settings + let hPad: CGFloat = 28 + let topPad: CGFloat = 80 + let bottomPad: CGFloat = 56 + + GeometryReader { geo in + ZStack(alignment: .bottom) { + Text(text) + .frame(width: geo.size.width - hPad * 2, alignment: .topLeading) + .frame(maxHeight: .infinity, alignment: .top) + .padding(.horizontal, hPad) + .padding(.top, topPad) + .padding(.bottom, bottomPad) + .frame(maxWidth: .infinity) + + Text("\(pageNumber) of \(totalPages)") + .font(.system(size: 11, weight: .regular).monospacedDigit()) + .foregroundStyle(settings.theme.textColor.opacity(0.3)) + .padding(.bottom, bottomPad - 24) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(settings.theme.backgroundColor) + } + } +} + +// MARK: - Chapter title page + +private struct ChapterTitlePage: View { + let content: ChapterResponse + let readerSettings: ReaderSettingsStore + + private var totalChapters: Int { + content.chapters.last?.number ?? content.chapter.number + } + + private var progressPercent: Int { + guard totalChapters > 1 else { return 100 } + return Int((Double(content.chapter.number) / Double(totalChapters)) * 100) + } + + private var accentColor: Color { + readerSettings.settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + } + + var body: some View { + let settings = readerSettings.settings + GeometryReader { geo in + VStack(alignment: .leading, spacing: 0) { + Spacer() + + VStack(alignment: .leading, spacing: 14) { + Text(content.book.title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(settings.theme.textColor.opacity(0.45)) + .textCase(.uppercase) + .tracking(1.4) + .lineLimit(2) + + Rectangle() + .fill(accentColor) + .frame(width: 36, height: 2) + .clipShape(Capsule()) + + Text(content.chapter.title.strippingTrailingDate()) + .font(.system(size: min(32, geo.size.width / 10.5), weight: .bold, design: .serif)) + .foregroundStyle(settings.theme.textColor) + .fixedSize(horizontal: false, vertical: true) + .lineSpacing(4) + + HStack(spacing: 8) { + if !content.chapter.dateLabel.isEmpty { + Text(content.chapter.dateLabel) + .font(.caption) + .foregroundStyle(settings.theme.textColor.opacity(0.4)) + } + if totalChapters > 1 { + if !content.chapter.dateLabel.isEmpty { + Circle() + .fill(settings.theme.textColor.opacity(0.25)) + .frame(width: 3, height: 3) + } + Text("\(progressPercent)% through") + .font(.caption.weight(.medium)) + .foregroundStyle(accentColor.opacity(0.85)) + } + } + } + .padding(.horizontal, 36) + + Spacer() + Spacer() + + HStack(spacing: 6) { + Image(systemName: "arrow.right") + .font(.caption2.weight(.semibold)) + Text("Swipe to read") + .font(.caption2) + } + .foregroundStyle(settings.theme.textColor.opacity(0.5)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.bottom, 96) + .phaseAnimator([false, true]) { v, phase in + v.offset(x: phase ? 4 : -2).opacity(phase ? 0.55 : 0.15) + } animation: { _ in .easeInOut(duration: 0.9) } + } + .frame(maxWidth: .infinity) + .background(settings.theme.backgroundColor) + } + } +} + +// MARK: - Chapter end page + +private struct ChapterEndPage: View { + let content: ChapterResponse + let readerSettings: ReaderSettingsStore + let onNavigateChapter: (Int) -> Void + + @State private var appeared = false + + private var accentColor: Color { + readerSettings.settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + } + + var body: some View { + let settings = readerSettings.settings + VStack(spacing: 32) { + Spacer() + + VStack(spacing: 20) { + ZStack { + Circle().fill(accentColor.opacity(0.07)).frame(width: 96, height: 96) + Circle().fill(accentColor.opacity(0.14)).frame(width: 72, height: 72) + Image(systemName: "checkmark") + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(accentColor) + .symbolEffect(.bounce, value: appeared) + } + .scaleEffect(appeared ? 1 : 0.7) + .opacity(appeared ? 1 : 0) + .animation(.spring(response: 0.5, dampingFraction: 0.65).delay(0.05), value: appeared) + + VStack(spacing: 6) { + Text("Chapter \(content.chapter.number)") + .font(.caption.weight(.semibold)) + .foregroundStyle(accentColor) + .textCase(.uppercase) + .tracking(1.2) + Text("Complete") + .font(.title2.bold()) + .foregroundStyle(settings.theme.textColor) + if content.next == nil { + Text("You've reached the latest chapter") + .font(.subheadline) + .foregroundStyle(settings.theme.textColor.opacity(0.4)) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 10) + .animation(.easeOut(duration: 0.35).delay(0.15), value: appeared) + } + + if let next = content.next { + Button { onNavigateChapter(next) } label: { + HStack(spacing: 8) { + Text("Chapter \(next)").fontWeight(.semibold) + Image(systemName: "arrow.right").font(.system(size: 14, weight: .semibold)) + } + .foregroundStyle(.white) + .frame(height: 52) + .frame(maxWidth: 240) + .background(Capsule().fill(accentColor)) + } + .buttonStyle(.plain) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 12) + .animation(.easeOut(duration: 0.35).delay(0.25), value: appeared) + .accessibilityLabel("Go to chapter \(next)") + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(settings.theme.backgroundColor) + .onAppear { appeared = true } + .onDisappear { appeared = false } + } +} + +// MARK: - Chapter progress bar + +private struct ChapterProgressBar: View { + let currentChapter: Int + let totalChapters: Int + let color: Color + + private var progress: Double { + guard totalChapters > 1 else { return 1.0 } + return Double(currentChapter) / Double(totalChapters) + } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Rectangle().fill(color.opacity(0.10)) + Rectangle() + .fill(LinearGradient( + colors: [color.opacity(0.7), color], + startPoint: .leading, + endPoint: .trailing + )) + .frame(width: geo.size.width * progress) + .animation(.spring(response: 0.5, dampingFraction: 0.85), value: progress) + } + } + .frame(height: 3) + } +} + +// MARK: - Listen button + +/// Isolated sub-view to avoid re-rendering ChapterReaderView on every audioPlayer update. +private struct ListenButton: View { + @ObservedObject var audioPlayer: AudioPlayerService + let vm: ChapterReaderViewModel + @ObservedObject var authStore: AuthStore + let theme: ReaderTheme + + private var isActive: Bool { + audioPlayer.isActive && audioPlayer.slug == vm.slug && audioPlayer.chapter == vm.chapter + } + + private var isGenerating: Bool { + audioPlayer.status == .generating + && audioPlayer.slug == vm.slug + && audioPlayer.chapter == vm.chapter + } + + private var accentColor: Color { + theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + } + + var body: some View { + Button { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings) + } label: { + HStack(spacing: 7) { + if isGenerating { + ProgressView() + .scaleEffect(0.75) + .tint(isActive ? .white : accentColor) + } else { + Image(systemName: isActive ? "waveform" : "headphones") + .font(.system(size: 15, weight: .semibold)) + .contentTransition(.symbolEffect(.replace.downUp)) + .symbolEffect(.variableColor.cumulative, isActive: isActive) + } + Text(isGenerating ? "Generating…" : (isActive ? "Listening" : "Listen")) + .font(.subheadline.weight(.semibold)) + } + .foregroundStyle(isActive ? .white : accentColor) + .padding(.horizontal, 18) + .padding(.vertical, 10) + .background(Capsule().fill(isActive ? accentColor : accentColor.opacity(0.13))) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isActive) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isGenerating) + .accessibilityLabel(isGenerating ? "Generating audio" : (isActive ? "Pause audio" : "Listen")) + } +} + +// MARK: - Download audio button + +private struct DownloadAudioButton: View { + let slug: String + let chapter: Int + let voice: String + let theme: ReaderTheme + + @EnvironmentObject private var downloadService: AudioDownloadService + + private var key: String { "\(slug)::\(chapter)::\(voice)" } + private var isDownloaded: Bool { downloadService.isDownloaded(slug: slug, chapter: chapter, voice: voice) } + private var progress: Double? { downloadService.downloads[key]?.progress } + + private var accentColor: Color { + theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + } + private var iconColor: Color { theme.textColor.opacity(0.6) } + + var body: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + if isDownloaded { + try? downloadService.deleteDownload(slug: slug, chapter: chapter, voice: voice) + } else if downloadService.downloads[key] != nil { + downloadService.cancelDownload(slug: slug, chapter: chapter, voice: voice) + } else { + Task { try? await downloadService.download(slug: slug, chapter: chapter, voice: voice) } + } + } label: { + Group { + if let frac = progress { + // In-progress ring + ZStack { + Circle().stroke(accentColor.opacity(0.2), lineWidth: 2) + .frame(width: 22, height: 22) + Circle().trim(from: 0, to: frac) + .stroke(accentColor, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .frame(width: 22, height: 22) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 0.2), value: frac) + } + } else { + Image(systemName: isDownloaded ? "arrow.down.circle.fill" : "arrow.down.circle") + .font(.system(size: 20)) + .foregroundStyle(isDownloaded ? accentColor : iconColor) + .contentTransition(.symbolEffect(.replace.downUp)) + } + } + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isDownloaded ? "Delete downloaded audio" : "Download audio") + } +} + +// MARK: - Chapters list sheet (ToC) + +private struct ChaptersListSheet: View { + let chapters: [ChapterBrief] + let currentChapter: Int + let onChapterSelect: (Int) -> Void + + @State private var searchText = "" + + private var filtered: [ChapterBrief] { + guard !searchText.isEmpty else { return chapters } + return chapters.filter { $0.title.localizedCaseInsensitiveContains(searchText) } + } + + var body: some View { + NavigationStack { + List(filtered) { ch in + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + onChapterSelect(ch.number) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(ch.title) + .font(.subheadline) + .foregroundStyle(ch.number == currentChapter ? Color.amber : .primary) + Text("Chapter \(ch.number)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if ch.number == currentChapter { + Image(systemName: "bookmark.fill") + .font(.caption) + .foregroundStyle(Color.amber) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .listStyle(.plain) + .searchable(text: $searchText, prompt: "Search chapters") + .navigationTitle("Chapters") + .navigationBarTitleDisplayMode(.inline) + } + } +} + +// MARK: - Reader settings panel + +struct ReaderSettingsPanel: View { + @ObservedObject var store: ReaderSettingsStore + @Binding var isPresented: Bool + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Color(.systemGray4)) + .frame(width: 36, height: 5) + .padding(.top, 10) + .padding(.bottom, 18) + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 22) { + + // Font size + VStack(alignment: .leading, spacing: 10) { + ReaderSectionLabel("Font Size") + HStack(spacing: 0) { + Button { adjustFontSize(-1) } label: { + Text("A").font(.system(size: 13, weight: .regular)) + .frame(width: 44, height: 44).contentShape(Rectangle()) + } + .buttonStyle(.plain).foregroundStyle(.primary) + Slider( + value: Binding( + get: { store.settings.fontSize }, + set: { v in var s = store.settings; s.fontSize = v; store.update(s) } + ), + in: 12...26, step: 1 + ) + .tint(.amber) + .padding(.horizontal, 8) + Button { adjustFontSize(1) } label: { + Text("A").font(.system(size: 21, weight: .semibold)) + .frame(width: 44, height: 44).contentShape(Rectangle()) + } + .buttonStyle(.plain).foregroundStyle(.primary) + } + } + + Divider().padding(.horizontal, 4) + + // Font family + VStack(alignment: .leading, spacing: 10) { + ReaderSectionLabel("Font") + HStack(spacing: 8) { + ForEach(ReaderFont.allCases, id: \.self) { font in + ReaderFontChip(font: font, isSelected: store.settings.font == font) { + var s = store.settings; s.font = font; store.update(s) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + } + } + } + + Divider().padding(.horizontal, 4) + + // Theme + VStack(alignment: .leading, spacing: 10) { + ReaderSectionLabel("Theme") + HStack(spacing: 8) { + ForEach(ReaderTheme.allCases, id: \.self) { theme in + ReaderThemeChip(theme: theme, isSelected: store.settings.theme == theme) { + var s = store.settings; s.theme = theme; store.update(s) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + } + } + } + + Divider().padding(.horizontal, 4) + + // Line spacing + VStack(alignment: .leading, spacing: 10) { + ReaderSectionLabel("Line Spacing") + HStack(spacing: 8) { + Image(systemName: "text.alignleft") + .font(.system(size: 13)).foregroundStyle(.secondary).frame(width: 28) + Slider( + value: Binding( + get: { store.settings.lineSpacing }, + set: { v in var s = store.settings; s.lineSpacing = v; store.update(s) } + ), + in: 1.2...2.4, step: 0.1 + ) + .tint(.amber) + Image(systemName: "text.alignleft") + .font(.system(size: 20)).foregroundStyle(.secondary).frame(width: 28) + } + } + + Divider().padding(.horizontal, 4) + + // Scroll vs pages + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(store.settings.scrollMode ? "Scroll" : "Pages") + .font(.subheadline.weight(.medium)) + Text(store.settings.scrollMode + ? "Continuous vertical scroll" + : "Swipe horizontally between pages") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Toggle("", isOn: Binding( + get: { store.settings.scrollMode }, + set: { v in + var s = store.settings; s.scrollMode = v; store.update(s) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + )) + .tint(.amber) + .labelsHidden() + } + + Color.clear.frame(height: 8) + } + .padding(.horizontal, 20) + } + } + } + + private func adjustFontSize(_ delta: CGFloat) { + var s = store.settings + s.fontSize = max(12, min(26, s.fontSize + delta)) + store.update(s) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } +} + +// MARK: - ReaderSettingsStore + +final class ReaderSettingsStore: ObservableObject { + @Published private(set) var settings: ReaderSettings + + init() { settings = ReaderSettings.load() } + + func update(_ new: ReaderSettings) { + settings = new + new.save() + } +} + +// MARK: - Settings sub-components + +private struct ReaderSectionLabel: View { + let title: String + init(_ title: String) { self.title = title } + var body: some View { + Text(title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.8) + } +} + +private struct ReaderFontChip: View { + let font: ReaderFont + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(font.rawValue) + .font(font.fontName.map { Font.custom($0, size: 15) } ?? .system(size: 15)) + .frame(maxWidth: .infinity).frame(height: 46) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? Color.amber : Color.clear, lineWidth: 1.5) + ) + ) + .foregroundStyle(isSelected ? Color.amber : .primary) + .scaleEffect(isSelected ? 1.03 : 1.0) + } + .buttonStyle(.plain) + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected) + } +} + +private struct ReaderThemeChip: View { + let theme: ReaderTheme + let isSelected: Bool + let action: () -> Void + + private var label: String { + switch theme { + case .white: return "White" + case .sepia: return "Sepia" + case .night: return "Night" + } + } + + var body: some View { + Button(action: action) { + Text(label) + .font(.subheadline.weight(isSelected ? .semibold : .regular)) + .frame(maxWidth: .infinity).frame(height: 46) + .background(theme.backgroundColor) + .foregroundStyle(theme.textColor) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? Color.amber : Color(.systemGray4), + lineWidth: isSelected ? 2 : 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .scaleEffect(isSelected ? 1.03 : 1.0) + } + .buttonStyle(.plain) + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected) + } +} + +// MARK: - HTML → AttributedString parser + +enum HTMLParser { + static func stripLeadingChapterHeader(from html: String) -> String { + var result = html + for _ in 0..<3 { + let pattern = #"^(\s*]*>)(.*?)(

)"# + guard let regex = try? NSRegularExpression( + pattern: pattern, options: [.dotMatchesLineSeparators, .caseInsensitive] + ) else { break } + + guard let match = regex.firstMatch( + in: result, range: NSRange(result.startIndex..., in: result) + ) else { break } + + let innerRange = match.range(at: 2) + guard innerRange.location != NSNotFound, + let swiftRange = Range(innerRange, in: result) else { break } + + let inner = String(result[swiftRange]) + let plain = inner + .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard plain.range(of: #"^\d*\s*[Cc]hapter\s+\d+"#, options: .regularExpression) != nil + else { break } + + guard let fullRange = Range(match.range(at: 0), in: result) else { break } + result.removeSubrange(fullRange) + } + return result + } + + static func toAttributedString( + html: String, + fontSize: CGFloat, + lineSpacing: CGFloat, + fontName: String?, + textColor: Color + ) -> AttributedString { + let uiFont: UIFont = fontName.flatMap { UIFont(name: $0, size: fontSize) } + ?? UIFont.systemFont(ofSize: fontSize) + + let uiColor = UIColor(textColor) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = (lineSpacing - 1.0) * fontSize + paragraphStyle.paragraphSpacing = fontSize * 0.7 + + let cleanedHtml = stripLeadingChapterHeader(from: html) + let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue + ] + + let nsAttr: NSMutableAttributedString + if let parsed = try? NSMutableAttributedString( + data: Data(cleanedHtml.utf8), options: options, documentAttributes: nil + ) { + nsAttr = parsed + } else { + let plain = cleanedHtml.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + nsAttr = NSMutableAttributedString(string: plain) + } + + let fullRange = NSRange(location: 0, length: nsAttr.length) + nsAttr.addAttribute(.font, value: uiFont, range: fullRange) + nsAttr.addAttribute(.foregroundColor, value: uiColor, range: fullRange) + nsAttr.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange) + + return (try? AttributedString(nsAttr, including: \.uiKit)) ?? AttributedString(nsAttr.string) + } +} + +// MARK: - Text paginator + +enum TextPaginator { + static func paginate( + attributed: AttributedString, + width: CGFloat, + height: CGFloat, + fontSize: CGFloat + ) -> [AttributedString] { + guard width > 0, height > 0 else { return [attributed] } + let nsAttr = NSAttributedString(attributed) + guard nsAttr.length > 0 else { return [] } + + let framesetter = CTFramesetterCreateWithAttributedString(nsAttr) + let path = CGPath(rect: CGRect(x: 0, y: 0, width: width, height: height), transform: nil) + + var pages: [AttributedString] = [] + var startIndex = 0 + let totalLength = nsAttr.length + var guard_ = 0 + + while startIndex < totalLength { + guard_ += 1 + if guard_ > 2000 { break } + + let range = CFRange(location: startIndex, length: totalLength - startIndex) + let frame = CTFramesetterCreateFrame(framesetter, range, path, nil) + let visible = CTFrameGetVisibleStringRange(frame) + + let pageLength = visible.length > 0 ? visible.length : max(1, totalLength - startIndex) + let endIndex = min(startIndex + pageLength, totalLength) + + let pageAttr = nsAttr.attributedSubstring(from: NSRange(location: startIndex, length: endIndex - startIndex)) + if let pageAS = try? AttributedString(pageAttr, including: \.uiKit) { + pages.append(pageAS) + } + + if visible.length <= 0 { break } + startIndex = endIndex + } + + return pages.isEmpty ? [attributed] : pages + } +} + + diff --git a/ios/LibNovelV2/Views/Common/CommonViews.swift b/ios/LibNovelV2/Views/Common/CommonViews.swift new file mode 100644 index 0000000..27ae76e --- /dev/null +++ b/ios/LibNovelV2/Views/Common/CommonViews.swift @@ -0,0 +1,235 @@ +import SwiftUI + +// MARK: - CommonViews +// Shared reusable components used across multiple screens. +// No external dependencies — images are loaded via URLSession with an in-memory cache. + +// MARK: - Color extensions (design system tokens) + +extension Color { + /// Amber-400 accent — #f59e0b + static let amber = Color(red: 0.961, green: 0.620, blue: 0.043) +} + +// MARK: - AsyncCoverImage +// URLSession-backed cover image loader with in-memory cache. +// Displays a zinc-800 placeholder skeleton while loading, book-closed icon on failure. + +private actor ImageCache { + static let shared = ImageCache() + private var cache: [URL: Data] = [:] + private var inFlight: [URL: Task] = [:] + + func data(for url: URL) async -> Data? { + if let cached = cache[url] { return cached } + if let existing = inFlight[url] { return await existing.value } + + let task = Task { + do { + let (d, _) = try await URLSession.shared.data(from: url) + return d + } catch { return nil } + } + inFlight[url] = task + let result = await task.value + inFlight.removeValue(forKey: url) + if let result { cache[url] = result } + return result + } +} + +struct AsyncCoverImage: View { + let url: String? + /// When true the placeholder is a plain colour fill (used for blurred hero backgrounds). + var isBackground: Bool = false + + @State private var image: UIImage? + @State private var hasFailed = false + + var body: some View { + Group { + if let image { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else if hasFailed { + placeholder + } else { + placeholder + .task(id: url) { await load() } + } + } + } + + @ViewBuilder + private var placeholder: some View { + if isBackground { + Color(uiColor: UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1)) + } else { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(uiColor: UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1))) + .overlay( + Image(systemName: "book.closed") + .font(.title3) + .foregroundStyle(.tertiary) + ) + } + } + + private func load() async { + guard let urlString = url, let parsedURL = URL(string: urlString) else { + hasFailed = true + return + } + guard let data = await ImageCache.shared.data(for: parsedURL), + let loaded = UIImage(data: data) else { + hasFailed = true + return + } + image = loaded + } +} + +// MARK: - EmptyStateView + +struct EmptyStateView: View { + let icon: String + let title: String + let message: String + var ctaLabel: String? = nil + var ctaAction: (() -> Void)? = nil + + var body: some View { + VStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 52)) + .foregroundStyle(.tertiary) + .symbolEffect(.pulse) + + Text(title) + .font(.headline) + .foregroundStyle(.primary) + + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + if let label = ctaLabel, let action = ctaAction { + Button(action: action) { + Text(label) + .font(.subheadline.bold()) + .foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1))) + .padding(.horizontal, 24) + .frame(height: 44) + .background(Color.amber) + .clipShape(Capsule()) + } + .padding(.top, 4) + } + } + } +} + +// MARK: - ShelfHeader +// Amber accent-bar + bold title. Used by Home, Profile, Browse shelves. + +struct ShelfHeader: View { + let title: String + + var body: some View { + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(Color.amber) + .frame(width: 3, height: 18) + Text(title) + .font(.title3.bold()) + } + .padding(.horizontal, 16) + .padding(.bottom, 10) + } +} + +// MARK: - ChipButton +// Unified selection chip (filled or outlined style). + +enum ChipButtonStyle { case filled, outlined } + +struct ChipButton: View { + let label: String + let isSelected: Bool + var style: ChipButtonStyle = .filled + let action: () -> Void + + var body: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + action() + } label: { + Text(label) + .font(style == .filled + ? .caption.weight(isSelected ? .semibold : .regular) + : .subheadline.weight(isSelected ? .semibold : .regular)) + .padding(.horizontal, style == .filled ? 12 : 14) + .padding(.vertical, 6) + .foregroundStyle(isSelected + ? (style == .filled ? Color.white : Color.amber) + : Color.primary) + .background(chipBackground) + } + .buttonStyle(.plain) + .frame(minWidth: 44, minHeight: 44) + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + } + + @ViewBuilder + private var chipBackground: some View { + switch style { + case .filled: + Capsule().fill(isSelected ? Color.amber : Color(.systemGray5)) + case .outlined: + Capsule() + .fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6)) + .overlay(Capsule().stroke(isSelected ? Color.amber : Color.clear, lineWidth: 1.5)) + } + } +} + +// MARK: - TagChip (read-only label) + +struct TagChip: View { + let label: String + + var body: some View { + Text(label) + .font(.caption2.bold()) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(.systemGray5), in: Capsule()) + } +} + +// MARK: - OfflineBanner +// Shown at the top of any view when the device is offline. + +struct OfflineBanner: View { + @EnvironmentObject var networkMonitor: NetworkMonitor + + var body: some View { + if !networkMonitor.isConnected { + HStack(spacing: 8) { + Image(systemName: "wifi.slash") + .font(.caption.bold()) + Text("You're offline — showing cached content") + .font(.caption) + Spacer() + } + .foregroundStyle(.primary) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.regularMaterial) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } +} diff --git a/ios/LibNovelV2/Views/Downloads/DownloadsView.swift b/ios/LibNovelV2/Views/Downloads/DownloadsView.swift new file mode 100644 index 0000000..d485448 --- /dev/null +++ b/ios/LibNovelV2/Views/Downloads/DownloadsView.swift @@ -0,0 +1,359 @@ +import SwiftUI + +// MARK: - DownloadsView +// Shows active downloads (in-progress), downloaded chapters grouped by book, and storage usage. +// Purely local — no network calls needed. + +struct DownloadsView: View { + @ObservedObject private var downloadService = AudioDownloadService.shared + @Environment(\.dismiss) private var dismiss + + // Completed chapters grouped by slug, sorted alphabetically + private var groupedDownloads: [(slug: String, keys: [String])] { + let slugs = downloadService.offlineBookSlugs() + return slugs.map { slug in + let keys = downloadService.downloadedChapters + .filter { $0.hasPrefix("\(slug)::") } + .sorted { lhs, rhs in + let lhsChapter = chapterNumber(from: lhs) + let rhsChapter = chapterNumber(from: rhs) + return lhsChapter < rhsChapter + } + return (slug: slug, keys: keys) + } + } + + private var activeDownloads: [(key: String, progress: DownloadProgress)] { + downloadService.downloads + .sorted { $0.key < $1.key } + .map { (key: $0.key, progress: $0.value) } + } + + private var storageFormatted: String { + ByteCountFormatter.string( + fromByteCount: downloadService.totalStorageUsed(), + countStyle: .file + ) + } + + private var hasAnyContent: Bool { + !downloadService.downloadedChapters.isEmpty || !downloadService.downloads.isEmpty + } + + var body: some View { + NavigationStack { + Group { + if hasAnyContent { + contentList + } else { + emptyState + } + } + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .navigationTitle("Downloads") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + dismiss() + } + .foregroundStyle(Color.amber) + } + } + } + } + + // MARK: - Content list + + private var contentList: some View { + List { + // Storage info + storageSection + + // Active downloads + if !activeDownloads.isEmpty { + Section { + ForEach(activeDownloads, id: \.key) { item in + ActiveDownloadRow(key: item.key, progress: item.progress) + } + } header: { + Text("Downloading") + .font(.subheadline.bold()) + .foregroundStyle(Color.amber) + .textCase(nil) + } + } + + // Completed, grouped by book + ForEach(groupedDownloads, id: \.slug) { group in + Section { + ForEach(group.keys, id: \.self) { key in + DownloadedChapterRow(key: key) + } + } header: { + HStack(spacing: 6) { + Image(systemName: "book.closed.fill") + .font(.caption) + .foregroundStyle(.secondary) + Text(group.slug) + .font(.subheadline.bold()) + .foregroundStyle(.primary) + .textCase(nil) + Spacer() + Text("\(group.keys.count) ch.") + .font(.caption) + .foregroundStyle(.secondary) + .textCase(nil) + } + } + } + + // Delete all + if !downloadService.downloadedChapters.isEmpty { + Section { + Button(role: .destructive) { + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + try? downloadService.deleteAllDownloads() + } label: { + HStack { + Spacer() + Label("Delete All Downloads", systemImage: "trash.fill") + .font(.subheadline.bold()) + Spacer() + } + } + .accessibilityLabel("Delete all downloaded audio chapters") + } + } + } + .scrollContentBackground(.hidden) + .listStyle(.insetGrouped) + } + + // MARK: - Storage section + + private var storageSection: some View { + Section { + HStack(spacing: 12) { + Image(systemName: "internaldrive.fill") + .font(.body) + .foregroundStyle(Color.amber) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text("Storage Used") + .font(.subheadline) + Text(storageFormatted) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text("\(downloadService.downloadedChapters.count) chapters") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + + // MARK: - Empty state + + private var emptyState: some View { + VStack(spacing: 0) { + Spacer() + EmptyStateView( + icon: "arrow.down.circle", + title: "No Downloads", + message: "Downloaded audio chapters appear here for offline listening." + ) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Helpers + + private func chapterNumber(from key: String) -> Int { + let parts = key.split(separator: "::") + guard parts.count >= 2, let n = Int(parts[1]) else { return 0 } + return n + } +} + +// MARK: - ActiveDownloadRow + +private struct ActiveDownloadRow: View { + let key: String + let progress: DownloadProgress + @ObservedObject private var downloadService = AudioDownloadService.shared + + var body: some View { + HStack(spacing: 12) { + // Icon with status + ZStack { + Circle() + .fill(statusColor.opacity(0.15)) + .frame(width: 36, height: 36) + Image(systemName: statusIcon) + .font(.subheadline.bold()) + .foregroundStyle(statusColor) + .contentTransition(.symbolEffect(.replace.downUp)) + } + + VStack(alignment: .leading, spacing: 3) { + Text("Chapter \(progress.chapter)") + .font(.subheadline.bold()) + .lineLimit(1) + + HStack(spacing: 4) { + Text(progress.slug) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Text("·") + .font(.caption) + .foregroundStyle(.tertiary) + Text(formatVoice(progress.voice)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + // Progress or error indicator + if case .failed(let msg) = progress.status { + VStack(alignment: .trailing, spacing: 2) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.subheadline) + .foregroundStyle(.red) + .symbolEffect(.pulse) + Text("Failed") + .font(.caption2) + .foregroundStyle(.red) + } + .accessibilityLabel("Download failed: \(msg)") + } else { + VStack(alignment: .trailing, spacing: 4) { + Text("\(Int(progress.progress * 100))%") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + ProgressView(value: progress.progress) + .tint(Color.amber) + .frame(width: 64) + } + } + + // Cancel button (only while downloading) + if progress.status == .downloading { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + downloadService.cancelDownload( + slug: progress.slug, + chapter: progress.chapter, + voice: progress.voice + ) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .frame(minWidth: 44, minHeight: 44) + .accessibilityLabel("Cancel download for chapter \(progress.chapter)") + } + } + .padding(.vertical, 4) + } + + private var statusColor: Color { + if case .failed = progress.status { return .red } + return Color.amber + } + + private var statusIcon: String { + if case .failed = progress.status { return "exclamationmark.triangle" } + return "arrow.down" + } +} + +// MARK: - DownloadedChapterRow + +private struct DownloadedChapterRow: View { + let key: String + @ObservedObject private var downloadService = AudioDownloadService.shared + + // Parse "slug::chapterNumber::voice" — v2 keys use "::" separator + private var components: (slug: String, chapter: Int, voice: String) { + let parts = key.split(separator: "::") + guard parts.count == 3, let chapter = Int(parts[1]) else { + return ("", 0, "") + } + return (String(parts[0]), chapter, String(parts[2])) + } + + var body: some View { + let c = components + HStack(spacing: 12) { + // Checkmark badge + ZStack { + Circle() + .fill(Color.green.opacity(0.15)) + .frame(width: 36, height: 36) + Image(systemName: "checkmark") + .font(.caption.bold()) + .foregroundStyle(.green) + } + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 3) { + Text("Chapter \(c.chapter)") + .font(.subheadline.bold()) + .lineLimit(1) + + Text(formatVoice(c.voice)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "waveform") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 4) + .accessibilityLabel("Chapter \(c.chapter), voice \(formatVoice(c.voice)), downloaded") + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + try? downloadService.deleteDownload( + slug: c.slug, + chapter: c.chapter, + voice: c.voice + ) + } label: { + Label("Delete", systemImage: "trash") + } + } + } +} + +// MARK: - Shared voice formatter + +private func formatVoice(_ voice: String) -> String { + let parts = voice.split(separator: "_") + guard parts.count == 2 else { return voice } + let prefix = String(parts[0]) + let name = String(parts[1]).capitalized + let gender = prefix.hasSuffix("f") ? "F" : prefix.hasSuffix("m") ? "M" : "" + let accent = prefix.hasPrefix("af") || prefix.hasPrefix("am") ? "US" + : prefix.hasPrefix("bf") || prefix.hasPrefix("bm") ? "UK" + : "" + if !gender.isEmpty && !accent.isEmpty { return "\(name) (\(accent) \(gender))" } + if !gender.isEmpty { return "\(name) (\(gender))" } + return name +} diff --git a/ios/LibNovelV2/Views/Home/HomeView.swift b/ios/LibNovelV2/Views/Home/HomeView.swift new file mode 100644 index 0000000..67617d9 --- /dev/null +++ b/ios/LibNovelV2/Views/Home/HomeView.swift @@ -0,0 +1,384 @@ +import SwiftUI + +// MARK: - HomeView +// "Reading Now" tab: stats bar + Continue Reading shelf + Recently Updated shelf +// + Subscription Feed shelf + empty state. +// Design mirrors the web UI home page (zinc-900 bg, amber accents, horizontal shelves). + +struct HomeView: View { + @State private var vm = HomeViewModel() + @EnvironmentObject var networkMonitor: NetworkMonitor + @EnvironmentObject var authStore: AuthStore + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + OfflineBanner() + + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + + + // ── Stats bar ─────────────────────────────────────── + if let stats = vm.stats { + StatsBar(stats: stats) + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 28) + .transition(.opacity) + } + + // ── Continue Reading ──────────────────────────────── + if !vm.continueReading.isEmpty { + ShelfHeader(title: "Continue Reading") + horizontalShelf { + ForEach(vm.continueReading) { item in + NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) { + ContinueReadingCard(item: item) + } + .buttonStyle(.plain) + .contextMenu { + continueReadingContextMenu(item: item) + } + } + } + } + + // ── Recently Updated ──────────────────────────────── + if !vm.recentlyUpdated.isEmpty { + ShelfHeader(title: "Recently Updated") + horizontalShelf { + ForEach(vm.recentlyUpdated) { book in + NavigationLink(value: NavDestination.book(book.slug)) { + ShelfBookCard(book: book) + } + .buttonStyle(.plain) + } + } + } + + // ── Subscription Feed ─────────────────────────────── + if !vm.subscriptionFeed.isEmpty { + ShelfHeader(title: "From People You Follow") + horizontalShelf { + ForEach(vm.subscriptionFeed) { item in + NavigationLink(value: NavDestination.book(item.book.slug)) { + SubscriptionFeedCard(item: item) + } + .buttonStyle(.plain) + } + } + } + + // ── Empty state ───────────────────────────────────── + if !vm.isLoading && + vm.continueReading.isEmpty && + vm.recentlyUpdated.isEmpty && + vm.subscriptionFeed.isEmpty { + EmptyStateView( + icon: "books.vertical", + title: "Your library is empty", + message: "Head to Discover to find novels to read.", + ctaLabel: "Discover Novels", + ctaAction: nil // tab switching handled externally + ) + .frame(maxWidth: .infinity) + .padding(.top, 60) + } + + // ── Loading indicator ─────────────────────────────── + if vm.isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 60) + } + + Color.clear.frame(height: 24) + } + } + .refreshable { await vm.load() } + } + .navigationTitle("Reading Now") + .appNavigationDestination() + .task { + guard networkMonitor.isConnected else { return } + await vm.load() + } + .errorAlert($vm.error) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: vm.isLoading) + } + } + + // MARK: - Horizontal shelf wrapper + + @ViewBuilder + private func horizontalShelf(@ViewBuilder content: () -> Content) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top, spacing: 14) { + content() + } + .padding(.horizontal, 16) + .padding(.bottom, 4) + } + .padding(.bottom, 28) + } + + // MARK: - Context menu for continue reading cards + + @ViewBuilder + private func continueReadingContextMenu(item: ContinueReadingItem) -> some View { + let isFinished = item.book.totalChapters > 0 && item.chapter >= item.book.totalChapters + + ShareLink(item: shareURL(for: item.book)) { + Label("Share", systemImage: "square.and.arrow.up") + } + + if !isFinished { + Button { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + Task { await markAsFinished(item.book) } + } label: { + Label("Mark as Finished", systemImage: "checkmark.circle") + } + } + + Button(role: .destructive) { + Task { await removeFromLibrary(item.book.slug) } + } label: { + Label("Remove from Library", systemImage: "trash") + } + } + + // MARK: - Actions + + private func markAsFinished(_ book: Book) async { + do { + try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters) + await vm.load() + } catch { + vm.error = error.localizedDescription + } + } + + private func removeFromLibrary(_ slug: String) async { + do { + try await APIClient.shared.deleteProgress(slug: slug) + await vm.load() + } catch { + vm.error = error.localizedDescription + } + } + + private func shareURL(for book: Book) -> URL { + let base = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String + ?? "https://v2.libnovel.kalekber.cc" + return URL(string: "\(base)/books/\(book.slug)")! + } +} + +// MARK: - Stats bar +// Three amber-value cards: Books / Chapters / In Progress + +private struct StatsBar: View { + let stats: HomeStats + + var body: some View { + HStack(spacing: 12) { + StatCard( + icon: "books.vertical.fill", + value: "\(stats.totalBooks)", + label: "Books" + ) + StatCard( + icon: "text.alignleft", + value: stats.totalChapters.formatted(), + label: "Chapters" + ) + StatCard( + icon: "bookmark.fill", + value: "\(stats.booksInProgress)", + label: "In Progress" + ) + } + } +} + +private struct StatCard: View { + let icon: String + let value: String + let label: String + + var body: some View { + VStack(spacing: 5) { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Color.amber) + Text(value) + .font(.title3.bold().monospacedDigit()) + .foregroundStyle(.primary) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } +} + +// MARK: - Continue Reading card (Apple Books style with progress bar) + +private struct ContinueReadingCard: View { + let item: ContinueReadingItem + + private static let cardWidth: CGFloat = 130 + private static let cardHeight: CGFloat = 188 // 2:3 aspect + + private var progressFraction: Double { + guard item.book.totalChapters > 0 else { return 0 } + return min(1.0, Double(item.chapter) / Double(item.book.totalChapters)) + } + + private var progressText: String { + let pct = progressFraction * 100 + if pct > 0 && pct < 10 { + return String(format: "%.1f%% complete", pct) + } + return "\(max(1, Int(round(pct))))% complete" + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Cover with gradient scrim + chapter badge + ZStack(alignment: .bottom) { + AsyncCoverImage(url: item.book.cover) + .frame(width: Self.cardWidth, height: Self.cardHeight) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .shadow(color: .black.opacity(0.22), radius: 8, y: 4) + .bookCoverZoomSource(slug: item.book.slug) + + // Gradient scrim + LinearGradient( + colors: [.clear, .black.opacity(0.55)], + startPoint: .center, + endPoint: .bottom + ) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .frame(height: 60) + + // Chapter pill + HStack(spacing: 4) { + Image(systemName: "play.fill") + .font(.system(size: 8, weight: .bold)) + Text("Ch.\(item.chapter)") + .font(.system(size: 10, weight: .bold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Capsule().fill(Color.amber)) + .padding(.bottom, 10) + } + + // Title + Text(item.book.title) + .font(.caption.bold()) + .lineLimit(2) + .frame(width: Self.cardWidth, alignment: .leading) + .foregroundStyle(.primary) + + // Progress bar (min 4pt sliver so early chapters are visible) + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.secondary.opacity(0.2)) + Capsule() + .fill(Color.amber.opacity(0.9)) + .frame(width: max(4, geo.size.width * progressFraction)) + } + } + .frame(width: Self.cardWidth, height: 3) + + Text(progressText) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(width: Self.cardWidth) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(item.book.title), chapter \(item.chapter), \(progressText)") + } +} + +// MARK: - Shelf book card (recently updated) + +private struct ShelfBookCard: View { + let book: Book + private static let cardWidth: CGFloat = 110 + private static let cardHeight: CGFloat = 158 + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ZStack(alignment: .topTrailing) { + AsyncCoverImage(url: book.cover) + .frame(width: Self.cardWidth, height: Self.cardHeight) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 4, y: 2) + .bookCoverZoomSource(slug: book.slug) + + Text("\(book.totalChapters) ch") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Capsule().fill(Color.black.opacity(0.55))) + .padding(6) + } + + Text(book.title) + .font(.caption.bold()) + .lineLimit(2) + .frame(width: Self.cardWidth, alignment: .leading) + + Text(book.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: Self.cardWidth, alignment: .leading) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(book.title) by \(book.author), \(book.totalChapters) chapters") + } +} + +// MARK: - Subscription feed card + +private struct SubscriptionFeedCard: View { + let item: SubscriptionFeedItem + private static let cardWidth: CGFloat = 110 + private static let cardHeight: CGFloat = 158 + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + AsyncCoverImage(url: item.book.cover) + .frame(width: Self.cardWidth, height: Self.cardHeight) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 4, y: 2) + .bookCoverZoomSource(slug: item.book.slug) + + Text(item.book.title) + .font(.caption.bold()) + .lineLimit(2) + .frame(width: Self.cardWidth, alignment: .leading) + + NavigationLink(value: NavDestination.userProfile(item.readerUsername)) { + Text("via @\(item.readerUsername)") + .font(.caption2) + .foregroundStyle(Color.amber) + .lineLimit(1) + .frame(width: Self.cardWidth, alignment: .leading) + } + .buttonStyle(.plain) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(item.book.title), via \(item.readerUsername)") + } +} diff --git a/ios/LibNovelV2/Views/Library/LibraryView.swift b/ios/LibNovelV2/Views/Library/LibraryView.swift new file mode 100644 index 0000000..1ac174d --- /dev/null +++ b/ios/LibNovelV2/Views/Library/LibraryView.swift @@ -0,0 +1,325 @@ +import SwiftUI + +// MARK: - LibraryView +// 2-column grid of saved books with progress overlay, genre/sort/reading-status filters. + +struct LibraryView: View { + @State private var viewModel = LibraryViewModel() + @EnvironmentObject private var networkMonitor: NetworkMonitor + + // Sort sheet + @State private var showingSortSheet = false + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + OfflineBanner() + + // Filter bar + filterBar + + if viewModel.isLoading && viewModel.items.isEmpty { + loadingState + } else if viewModel.filteredItems.isEmpty && !viewModel.isLoading { + emptyState + } else { + bookGrid + } + } + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .navigationTitle("Library") + .navigationBarTitleDisplayMode(.large) + .toolbar { toolbarContent } + .appNavigationDestination() + .task { + guard networkMonitor.isConnected else { return } + await viewModel.load() + } + .refreshable { await viewModel.load() } + .errorAlert($viewModel.error) + .confirmationDialog("Sort By", isPresented: $showingSortSheet, titleVisibility: .visible) { + ForEach(LibrarySortOrder.allCases, id: \.self) { order in + Button(order.rawValue) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.sortOrder = order + } + } + } + Button("Cancel", role: .cancel) {} + } + } + } + + // MARK: - Filter bar + + private var filterBar: some View { + VStack(spacing: 0) { + // Reading filter chips + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(LibraryReadingFilter.allCases, id: \.self) { filter in + ChipButton(label: filter.rawValue, + isSelected: viewModel.readingFilter == filter, + style: .filled) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.readingFilter = filter + } + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + // Genre chips (only show if there are genres) + if viewModel.allGenres.count > 1 { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(viewModel.allGenres, id: \.self) { genre in + ChipButton(label: genre, + isSelected: viewModel.selectedGenre == genre, + style: .outlined) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.selectedGenre = genre + } + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + } + + Divider() + .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) + } + } + + // MARK: - Book grid + + private let columns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ] + + private var bookGrid: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(viewModel.filteredItems) { item in + NavigationLink(value: NavDestination.book(item.book.slug)) { + LibraryBookCard( + item: item, + progress: viewModel.progressFraction(for: item), + progressLabel: viewModel.progressPercent(for: item), + isCompleted: viewModel.isCompleted(for: item), + lastChapter: viewModel.lastChapter(for: item) + ) + .bookCoverZoomSource(slug: item.book.slug) + .contextMenu { + contextMenu(for: item) + } + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 120) // clear mini player + } + } + + // MARK: - Context menu + + @ViewBuilder + private func contextMenu(for item: LibraryItem) -> some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + // Share: nothing to share without a URL from API, placeholder + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + + if !viewModel.isCompleted(for: item) { + Button { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + Task { await viewModel.markFinished(item: item) } + } label: { + Label("Mark as Finished", systemImage: "checkmark.circle") + } + } + + Button(role: .destructive) { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + Task { await viewModel.removeFromLibrary(slug: item.book.slug) } + } label: { + Label("Remove from Library", systemImage: "trash") + } + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showingSortSheet = true + } label: { + Label("Sort", systemImage: "arrow.up.arrow.down") + .labelStyle(.iconOnly) + } + .accessibilityLabel("Sort library") + } + } + + // MARK: - Loading state + + private var loadingState: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(0..<8, id: \.self) { _ in + LibraryBookCardSkeleton() + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + } + } + + // MARK: - Empty state + + private var emptyState: some View { + VStack { + Spacer() + EmptyStateView( + icon: "books.vertical", + title: viewModel.items.isEmpty ? "Your library is empty" : "No books match", + message: viewModel.items.isEmpty + ? "Browse and save books to build your collection." + : "Try a different filter or genre.", + ctaLabel: viewModel.items.isEmpty ? "Browse Books" : nil, + ctaAction: nil + ) + Spacer() + } + } +} + +// MARK: - LibraryBookCard + +struct LibraryBookCard: View { + let item: LibraryItem + let progress: Double // 0…1 + let progressLabel: String // "47%" or "3.4%" + let isCompleted: Bool + let lastChapter: Int + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + // Cover with progress arc overlay + ZStack(alignment: .topTrailing) { + AsyncCoverImage(url: item.book.cover) + .aspectRatio(2/3, contentMode: .fill) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + + if isCompleted { + completedBadge + } else if progress > 0 { + progressArcBadge + } + } + + // Title + Text(item.book.title) + .font(.caption.bold()) + .foregroundStyle(.primary) + .lineLimit(2) + + // Chapter subtitle + if lastChapter > 0 { + Text(isCompleted ? "Completed" : "Ch. \(lastChapter)") + .font(.caption2) + .foregroundStyle(isCompleted ? Color.amber : .secondary) + } + } + } + + // MARK: - Completed badge + + private var completedBadge: some View { + Image(systemName: "checkmark.circle.fill") + .font(.title3) + .foregroundStyle(Color.amber) + .padding(6) + .background(.regularMaterial, in: Circle()) + .padding(6) + .accessibilityLabel("Completed") + } + + // MARK: - Progress arc + + private var progressArcBadge: some View { + ZStack { + // Track + Circle() + .stroke(Color.white.opacity(0.25), lineWidth: 3) + .frame(width: 32, height: 32) + + // Fill + Circle() + .trim(from: 0, to: progress) + .stroke(Color.amber, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .frame(width: 32, height: 32) + .animation(.spring(response: 0.5, dampingFraction: 0.7), value: progress) + + Text(progressLabel) + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(.white) + } + .padding(6) + .background(.ultraThinMaterial, in: Circle()) + .padding(6) + .accessibilityLabel("Progress: \(progressLabel)") + } +} + +// MARK: - LibraryBookCardSkeleton +// Shimmer placeholder used while data is loading. + +struct LibraryBookCardSkeleton: View { + @State private var phase: Double = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(shimmerGradient) + .aspectRatio(2/3, contentMode: .fill) + + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(height: 12) + + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: 60, height: 10) + } + .onAppear { + withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: true)) { + phase = 1 + } + } + } + + private var shimmerGradient: LinearGradient { + LinearGradient( + colors: [ + Color(uiColor: UIColor(red: 0.15, green: 0.15, blue: 0.17, alpha: 1)), + Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1)), + Color(uiColor: UIColor(red: 0.15, green: 0.15, blue: 0.17, alpha: 1)) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } +} diff --git a/ios/LibNovelV2/Views/Player/PlayerViews.swift b/ios/LibNovelV2/Views/Player/PlayerViews.swift new file mode 100644 index 0000000..b79f3ab --- /dev/null +++ b/ios/LibNovelV2/Views/Player/PlayerViews.swift @@ -0,0 +1,1826 @@ +import SwiftUI +import AVFoundation +import AVKit // AVRoutePickerView + +// MARK: - VoiceSelectionViewModel +// Minimal inline VM for the FullPlayerView voice panel and DownloadManagementSheet. +// New type → @Observable (iOS 17+). + +@Observable @MainActor +final class VoiceSelectionViewModel { + var voices: [String] = [] + var isLoading = false + var error: String? + var playingVoice: String? + + private var audioPlayer: AVPlayer? + private var endObserverToken: NSObjectProtocol? + + func voiceLabel(_ voice: String) -> String { + let parts = voice.split(separator: "_") + guard parts.count >= 2 else { return voice } + let prefix = String(parts[0]) + let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ") + var info = "" + switch prefix { + case "af": info = "US F" + case "am": info = "US M" + case "bf": info = "UK F" + case "bm": info = "UK M" + default: info = prefix.uppercased() + } + return "\(name) (\(info))" + } + + func voiceId(_ voice: String) -> String { voice } + + func loadVoices() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + let fetched = try await APIClient.shared.voices() + voices = fetched.isEmpty ? fallbackVoices() : fetched + } catch { + self.error = error.localizedDescription + voices = fallbackVoices() + } + } + + func playSample(_ voice: String) async { + if playingVoice == voice { stopSample(); return } + stopSample() + playingVoice = voice + do { + let url = try await APIClient.shared.presignVoiceSample(voice: voice) + guard let parsed = URL(string: url) else { playingVoice = nil; return } + let item = AVPlayerItem(url: parsed) + audioPlayer = AVPlayer(playerItem: item) + endObserverToken = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.stopSample() } + } + audioPlayer?.play() + } catch { + playingVoice = nil + } + } + + func stopSample() { + audioPlayer?.pause() + audioPlayer = nil + if let token = endObserverToken { + NotificationCenter.default.removeObserver(token) + endObserverToken = nil + } + playingVoice = nil + } + + private func fallbackVoices() -> [String] { + ["af_bella", "af_sarah", "af_nicole", + "am_adam", "am_michael", + "bf_emma", "bf_isabella", + "bm_george", "bm_lewis", "af_sky"] + } +} + +// MARK: - MiniPlayerBar +// Spotify-style bar fixed above the tab bar. +// Swipe up → full player. Swipe down → stop. + +struct MiniPlayerBar: View { + @Binding var showFullPlayer: Bool + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var downloadService: AudioDownloadService + + @State private var dragOffset: CGFloat = 0 + + private var isCurrentChapterDownloaded: Bool { + downloadService.isDownloaded( + slug: audioPlayer.slug, + chapter: audioPlayer.chapter, + voice: audioPlayer.voice + ) + } + + var body: some View { + VStack(spacing: 0) { + // Amber progress strip + MiniBarProgress(progress: audioPlayer.progress) + + HStack(spacing: 12) { + // Cover art + Button { showFullPlayer = true } label: { + AsyncCoverImage(url: audioPlayer.coverURL) + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + .shadow(color: .black.opacity(0.18), radius: 6, y: 2) + } + .buttonStyle(.plain) + .accessibilityLabel("Open full player") + + // Track info + Button { showFullPlayer = true } label: { + VStack(alignment: .leading, spacing: 2) { + Text(chapterLabel) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + HStack(spacing: 4) { + Text(audioPlayer.bookTitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + if isCurrentChapterDownloaded { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 9)) + .foregroundStyle(.green) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + + // Prev chapter + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + if let prev = audioPlayer.prevChapter { + NotificationCenter.default.post( + name: .skipToPrevChapter, object: nil, + userInfo: ["prev": prev] + ) + } + } label: { + Image(systemName: "backward.end.fill") + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 36, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(audioPlayer.prevChapter == nil) + .opacity(audioPlayer.prevChapter == nil ? 0.3 : 1) + .accessibilityLabel("Previous chapter") + + // Play / Pause — isolated observer + MiniBarPlayPause(progress: audioPlayer.progress) { + audioPlayer.togglePlayPause() + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + .disabled(audioPlayer.status == .generating) + + // Next chapter + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + if let next = audioPlayer.nextChapter { + NotificationCenter.default.post( + name: .skipToNextChapter, object: nil, + userInfo: ["next": next] + ) + } + } label: { + Image(systemName: "forward.end.fill") + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 36, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(audioPlayer.nextChapter == nil) + .opacity(audioPlayer.nextChapter == nil ? 0.3 : 1) + .accessibilityLabel("Next chapter") + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .background(.regularMaterial) + .offset(y: dragOffset) + .opacity(dragOffset > 0 ? max(0.3, 1 - dragOffset / 200) : 1) + .gesture( + DragGesture(minimumDistance: 8, coordinateSpace: .local) + .onChanged { value in + let dy = value.translation.height + dragOffset = dy < 0 ? dy * 0.25 : dy * 0.7 + } + .onEnded { value in + let dy = value.translation.height + let velocity = value.predictedEndTranslation.height - value.translation.height + if dy < -30 || velocity < -150 { + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { dragOffset = 0 } + showFullPlayer = true + } else if dy > 60 || velocity > 200 { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { dragOffset = 200 } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 150_000_000) + audioPlayer.stop() + } + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { dragOffset = 0 } + } + } + ) + } + + private var chapterLabel: String { + let raw = audioPlayer.chapterTitle.isEmpty + ? "Chapter \(audioPlayer.chapter)" + : audioPlayer.chapterTitle + return raw.strippingTrailingDate() + } +} + +// MARK: - Isolated progress strip + +private struct MiniBarProgress: View { + @ObservedObject var progress: PlaybackProgress + + var body: some View { + GeometryReader { geo in + let fraction = progress.duration > 0 + ? CGFloat(progress.currentTime / progress.duration) + : 0 + Rectangle() + .fill(Color.amber) + .frame(width: geo.size.width * max(0, min(1, fraction)), height: 2) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 2) + } +} + +// MARK: - Isolated play/pause for mini bar + +private struct MiniBarPlayPause: View { + @ObservedObject var progress: PlaybackProgress + let onToggle: () -> Void + + var body: some View { + Button(action: onToggle) { + Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 21, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 36, height: 44) + .contentShape(Rectangle()) + .contentTransition(.symbolEffect(.replace.downUp)) + } + .buttonStyle(.plain) + .accessibilityLabel(progress.isPlaying ? "Pause" : "Play") + } +} + +// MARK: - FullPlayerView + +struct FullPlayerView: View { + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var downloadService: AudioDownloadService + @EnvironmentObject var authStore: AuthStore + var onDismiss: () -> Void = {} + + @State private var showingChaptersList = false + @State private var showingSleepTimer = false + @State private var showingVoiceSelector = false + @State private var voiceVM = VoiceSelectionViewModel() + @State private var coverAppeared = false + + private var isCurrentChapterDownloaded: Bool { + downloadService.isDownloaded( + slug: audioPlayer.slug, + chapter: audioPlayer.chapter, + voice: audioPlayer.voice + ) + } + + private var currentDownloadProgress: DownloadProgress? { + let key = downloadService.makeKey( + slug: audioPlayer.slug, + chapter: audioPlayer.chapter, + voice: audioPlayer.voice + ) + return downloadService.downloads[key] + } + + var body: some View { + GeometryReader { geo in + ZStack { + // Blurred cover background + AsyncCoverImage(url: audioPlayer.coverURL, isBackground: true) + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + .blur(radius: 55, opaque: true) + .overlay(Color.black.opacity(0.55)) + .ignoresSafeArea() + .id(audioPlayer.coverURL) + + VStack(spacing: 0) { + // Drag handle + Capsule() + .fill(Color.white.opacity(0.25)) + .frame(width: 36, height: 4) + .padding(.top, 14) + + // Cover art + let coverSize = min(geo.size.width - 56, geo.size.height * 0.42) + ZStack { + AsyncCoverImage(url: audioPlayer.coverURL) + .frame(width: coverSize, height: coverSize) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + .shadow(color: .black.opacity(0.55), radius: 36, y: 18) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Color.black.opacity(audioPlayer.status == .generating ? 0.5 : 0)) + .animation(.easeInOut(duration: 0.3), value: audioPlayer.status == .generating) + ) + .scaleEffect(audioPlayer.progress.isPlaying && coverAppeared ? 1.02 : 0.97) + .animation(.spring(response: 0.45, dampingFraction: 0.7), value: audioPlayer.progress.isPlaying) + + // Generating overlay + if audioPlayer.status == .generating { + VStack(spacing: 10) { + ProgressView() + .tint(.white) + .scaleEffect(1.4) + Text("Generating audio…") + .font(.caption.weight(.medium)) + .foregroundStyle(.white.opacity(0.8)) + } + .transition(.opacity) + } + + // Voice watermark + VStack { + Spacer() + HStack { + Text(voiceName) + .font(.custom("Snell Roundhand", size: 17)) + .foregroundStyle(.white.opacity(0.5)) + .shadow(color: .black.opacity(0.5), radius: 2) + .padding(12) + Spacer() + } + } + .frame(width: coverSize, height: coverSize) + } + .frame(width: coverSize, height: coverSize) + .padding(.top, 18) + .onAppear { + withAnimation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1)) { + coverAppeared = true + } + } + .onChange(of: audioPlayer.slug) { _, _ in + coverAppeared = false + withAnimation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.05)) { + coverAppeared = true + } + } + + // Title block + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text((audioPlayer.chapterTitle.isEmpty + ? "Chapter \(audioPlayer.chapter)" + : audioPlayer.chapterTitle).strippingTrailingDate()) + .font(.title3.weight(.bold)) + .foregroundStyle(.white) + .lineLimit(2) + Text(audioPlayer.bookTitle) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + + HStack(spacing: 8) { + if !audioPlayer.chapters.isEmpty { + Text(chapterPositionText) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.white.opacity(0.3)) + } + if let p = currentDownloadProgress { + Label("\(Int(p.progress * 100))%", systemImage: "arrow.down.circle") + .font(.caption2) + .foregroundStyle(.blue) + } else if isCurrentChapterDownloaded { + Label("Offline", systemImage: "checkmark.circle.fill") + .font(.caption2) + .foregroundStyle(.green) + } + } + .padding(.top, 1) + } + .frame(maxWidth: .infinity, alignment: .leading) + + // Quick download + if !isCurrentChapterDownloaded && currentDownloadProgress == nil { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + Task { + try? await downloadService.download( + slug: audioPlayer.slug, + chapter: audioPlayer.chapter, + voice: audioPlayer.voice + ) + } + } label: { + Image(systemName: "arrow.down.circle") + .font(.system(size: 24)) + .foregroundStyle(.white.opacity(0.65)) + .frame(minWidth: 44, minHeight: 44) + } + .buttonStyle(.plain) + .accessibilityLabel("Download chapter") + } + + // Auto-next toggle + Button { + audioPlayer.autoNext.toggle() + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } label: { + Image(systemName: audioPlayer.autoNext ? "infinity.circle.fill" : "infinity.circle") + .font(.system(size: 28)) + .foregroundStyle(audioPlayer.autoNext ? Color.amber : .white.opacity(0.4)) + .contentTransition(.symbolEffect(.replace)) + .frame(minWidth: 44, minHeight: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(audioPlayer.autoNext ? "Auto-next on" : "Auto-next off") + } + .padding(.horizontal, 28) + .padding(.top, 22) + + // Seek bar (isolated) + PlayerProgressSection( + progress: audioPlayer.progress, + onSeek: { audioPlayer.seek(to: $0) } + ) + .padding(.top, 18) + .opacity(audioPlayer.status == .generating ? 0.3 : 1) + .allowsHitTesting(audioPlayer.status != .generating) + + // Transport row + HStack(spacing: 0) { + PlayerSecondaryButton(systemName: "gobackward.15", size: 24, + disabled: audioPlayer.status == .generating) { + audioPlayer.skip(by: -15) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + + PlayerChapterSkipButton(systemName: "backward.end.fill", size: 30, + disabled: audioPlayer.prevChapter == nil) { + if let prev = audioPlayer.prevChapter { + NotificationCenter.default.post( + name: .skipToPrevChapter, object: nil, userInfo: ["prev": prev]) + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + } + + PlayerPlayPauseButton( + progress: audioPlayer.progress, + isGenerating: audioPlayer.status == .generating + ) { + audioPlayer.togglePlayPause() + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + + PlayerChapterSkipButton( + systemName: "forward.end.fill", size: 30, + disabled: audioPlayer.nextChapter == nil, + prefetching: audioPlayer.nextPrefetchStatus == .prefetching + ) { + if let next = audioPlayer.nextChapter { + NotificationCenter.default.post( + name: .skipToNextChapter, object: nil, userInfo: ["next": next]) + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + } + + PlayerSecondaryButton(systemName: "goforward.15", size: 24, + disabled: audioPlayer.status == .generating) { + audioPlayer.skip(by: 15) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 8) + + // Bottom toolbar + HStack(spacing: 0) { + // AirPlay + AirPlayButton() + .frame(width: 24, height: 24) + .frame(maxWidth: .infinity, minHeight: 44) + .accessibilityLabel("AirPlay") + + // Speed picker + Menu { + ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in + Button { + audioPlayer.setSpeed(s) + } label: { + if s == audioPlayer.speed { + Label("\(s, specifier: "%.2g")×", systemImage: "checkmark") + } else { + Text("\(s, specifier: "%.2g")×") + } + } + } + } label: { + Text("\(audioPlayer.speed, specifier: "%.2g")×") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white.opacity(0.65)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Capsule().fill(.white.opacity(0.12))) + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .buttonStyle(.plain) + + // Voice selector toggle + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + showingVoiceSelector.toggle() + } + UIImpactFeedbackGenerator(style: .light).impactOccurred() + if !showingVoiceSelector { voiceVM.stopSample() } + } label: { + Image(systemName: showingVoiceSelector ? "mic.fill" : "mic") + .font(.system(size: 20)) + .foregroundStyle(showingVoiceSelector ? Color.amber : .white.opacity(0.65)) + .frame(maxWidth: .infinity) + .frame(height: 44) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + .accessibilityLabel(showingVoiceSelector ? "Hide voice selector" : "Select voice") + + // Collapse + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + onDismiss() + } label: { + Image(systemName: "chevron.down") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white.opacity(0.65)) + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel("Collapse player") + + // Chapters list + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showingChaptersList = true + } label: { + Image(systemName: "list.bullet") + .font(.system(size: 20)) + .foregroundStyle(.white.opacity(0.65)) + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel("Chapters list") + + // Sleep timer + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showingSleepTimer = true + } label: { + VStack(spacing: 1) { + Image(systemName: sleepTimerIcon) + .font(.system(size: 20)) + .foregroundStyle(audioPlayer.sleepTimer != nil ? Color.amber : .white.opacity(0.65)) + .contentTransition(.symbolEffect(.replace)) + if !audioPlayer.sleepTimerRemainingText.isEmpty { + Text(audioPlayer.sleepTimerRemainingText) + .font(.system(size: 9, weight: .semibold).monospacedDigit()) + .foregroundStyle(Color.amber) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(audioPlayer.sleepTimer != nil ? "Sleep timer active" : "Sleep timer") + } + .padding(.horizontal, 12) + .padding(.bottom, showingVoiceSelector ? 0 : 8) + + // Voice selector panel (expandable) + if showingVoiceSelector { + VoiceSelectorPanel( + voiceVM: voiceVM, + selectedVoice: audioPlayer.voice, + onSelectVoice: { newVoice in + voiceVM.stopSample() + audioPlayer.voice = newVoice + BookVoicePreferences.shared.setVoice(newVoice, for: audioPlayer.slug) + Task { + var settings = authStore.settings + settings.voice = newVoice + await authStore.saveSettings(settings) + } + } + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .task { + if voiceVM.voices.isEmpty { await voiceVM.loadVoices() } + } + } + } + .ignoresSafeArea(edges: .bottom) + } + } + .ignoresSafeArea() + .sheet(isPresented: $showingChaptersList) { + PlayerChaptersListSheet( + chapters: audioPlayer.chapters, + currentChapter: audioPlayer.chapter, + onChapterSelect: { selected in + showingChaptersList = false + guard selected != audioPlayer.chapter else { return } + let title = audioPlayer.chapters.first(where: { $0.number == selected })?.title ?? "" + let next = audioPlayer.chapters.filter({ $0.number > selected }).min(by: { $0.number < $1.number })?.number + let prev: Int? = selected > 1 ? selected - 1 : nil + audioPlayer.load( + slug: audioPlayer.slug, chapter: selected, chapterTitle: title, + bookTitle: audioPlayer.bookTitle, coverURL: audioPlayer.coverURL, + voice: audioPlayer.voice, speed: audioPlayer.speed, + chapters: audioPlayer.chapters, nextChapter: next, prevChapter: prev + ) + } + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $showingSleepTimer) { + SleepTimerSheet(audioPlayer: audioPlayer) + .presentationDetents([.height(500)]) + .presentationDragIndicator(.visible) + } + } + + // MARK: - Helpers + + private var chapterPositionText: String { + let total = audioPlayer.chapters.count + guard total > 0 else { return "" } + let sorted = audioPlayer.chapters.sorted(by: { $0.number < $1.number }) + let idx = (sorted.firstIndex(where: { $0.number == audioPlayer.chapter }) ?? 0) + 1 + return "Chapter \(idx) of \(total)" + } + + private var voiceName: String { + let parts = audioPlayer.voice.split(separator: "_") + if parts.count > 1 { return String(parts[1]).capitalized } + return audioPlayer.voice.capitalized + } + + private var sleepTimerIcon: String { + audioPlayer.sleepTimer != nil ? "moon.zzz.fill" : "moon.zzz" + } +} + +// MARK: - Secondary transport button (±15 s skips) + +private struct PlayerSecondaryButton: View { + let systemName: String + let size: CGFloat + let disabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: size, weight: .regular)) + .foregroundStyle(.white.opacity(disabled ? 0.3 : 0.85)) + .frame(maxWidth: .infinity) + .frame(height: 64) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + } +} + +// MARK: - Chapter-skip button (prev / next chapter) + +private struct PlayerChapterSkipButton: View { + let systemName: String + let size: CGFloat + let disabled: Bool + var prefetching: Bool = false + let action: () -> Void + + var body: some View { + Button(action: action) { + ZStack { + Image(systemName: systemName) + .font(.system(size: size, weight: .regular)) + .foregroundStyle(.white.opacity(disabled ? 0.3 : 0.9)) + + if prefetching { + VStack { + Spacer() + HStack { + Spacer() + ProgressView() + .scaleEffect(0.55) + .tint(.amber) + .padding(3) + .background(Circle().fill(.black.opacity(0.6))) + } + } + } + } + .frame(maxWidth: .infinity) + .frame(height: 64) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + .opacity(disabled ? 0.4 : 1.0) + } +} + +// MARK: - AirPlay Button + +struct AirPlayButton: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + let vc = UIViewController() + vc.view.backgroundColor = .clear + let picker = AVRoutePickerView() + picker.tintColor = UIColor.white.withAlphaComponent(0.7) + picker.activeTintColor = UIColor.systemOrange + picker.prioritizesVideoDevices = false + picker.translatesAutoresizingMaskIntoConstraints = false + vc.view.addSubview(picker) + NSLayoutConstraint.activate([ + picker.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor), + picker.trailingAnchor.constraint(equalTo: vc.view.trailingAnchor), + picker.topAnchor.constraint(equalTo: vc.view.topAnchor), + picker.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor), + ]) + return vc + } + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +// MARK: - Isolated seek bar + timestamps + +private struct PlayerProgressSection: View { + @ObservedObject var progress: PlaybackProgress + let onSeek: (Double) -> Void + + var body: some View { + VStack(spacing: 4) { + PlayerSlider( + value: Binding(get: { progress.currentTime }, set: { onSeek($0) }), + range: 0...max(progress.duration, 1) + ) + HStack { + Text(formatTime(progress.currentTime)) + Spacer() + Text("-" + formatTime(progress.duration - progress.currentTime)) + } + .font(.caption.monospacedDigit()) + .foregroundStyle(.white.opacity(0.5)) + } + .padding(.horizontal, 28) + } + + private func formatTime(_ seconds: Double) -> String { + guard seconds.isFinite, seconds >= 0 else { return "0:00" } + let s = Int(seconds) + return "\(s / 60):\(String(format: "%02d", s % 60))" + } +} + +// MARK: - Custom amber seek slider + +struct PlayerSlider: View { + @Binding var value: Double + let range: ClosedRange + + @State private var isDragging = false + @State private var didFireHaptic = false + + var body: some View { + GeometryReader { geo in + let width = geo.size.width + let fraction = (value - range.lowerBound) / (range.upperBound - range.lowerBound) + let clamped = max(0, min(1, fraction)) + let filled = width * clamped + let thumbSize: CGFloat = isDragging ? 26 : 20 + let trackHeight: CGFloat = isDragging ? 5 : 4 + + ZStack(alignment: .leading) { + Capsule() + .fill(Color.white.opacity(0.2)) + .frame(height: trackHeight) + + Capsule() + .fill(LinearGradient( + colors: [Color.amber.opacity(0.9), Color.amber], + startPoint: .leading, endPoint: .trailing + )) + .frame(width: max(filled, thumbSize / 2), height: trackHeight) + + Circle() + .fill(Color.white) + .frame(width: thumbSize, height: thumbSize) + .shadow(color: .black.opacity(0.3), radius: isDragging ? 6 : 3, + y: isDragging ? 2 : 1) + .offset(x: max(0, filled - thumbSize / 2)) + .animation(.spring(response: 0.2, dampingFraction: 0.65), value: isDragging) + } + .frame(height: 36) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { drag in + if !isDragging { + isDragging = true + if !didFireHaptic { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + didFireHaptic = true + } + } + let raw = drag.location.x / width + value = range.lowerBound + max(0, min(1, raw)) * (range.upperBound - range.lowerBound) + } + .onEnded { _ in isDragging = false; didFireHaptic = false } + ) + } + .frame(height: 36) + } +} + +// MARK: - Isolated play/pause button (full player) + +private struct PlayerPlayPauseButton: View { + @ObservedObject var progress: PlaybackProgress + let isGenerating: Bool + let onToggle: () -> Void + + @State private var isPressed = false + + var body: some View { + Button { onToggle() } label: { + ZStack { + Circle() + .fill(Color.amber.opacity(progress.isPlaying ? 0.18 : 0)) + .frame(width: 80, height: 80) + .animation(.easeInOut(duration: 0.35), value: progress.isPlaying) + + Circle() + .fill(LinearGradient( + colors: [Color.amber.opacity(0.9), Color.amber.opacity(0.65)], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + .frame(width: 64, height: 64) + .shadow(color: Color.amber.opacity(0.45), radius: 12, y: 4) + .scaleEffect(isPressed ? 0.92 : 1.0) + .animation(.spring(response: 0.2, dampingFraction: 0.6), value: isPressed) + + if isGenerating { + ProgressView().tint(.white).scaleEffect(1.2) + } else { + Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(.white) + .offset(x: progress.isPlaying ? 0 : 2) + .contentTransition(.symbolEffect(.replace.downUp)) + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .disabled(isGenerating) + ._onButtonGesture(pressing: { isPressed = $0 }, perform: {}) + .accessibilityLabel(progress.isPlaying ? "Pause" : "Play") + } +} + +// MARK: - Sleep Timer Sheet + +struct SleepTimerSheet: View { + @ObservedObject var audioPlayer: AudioPlayerService + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 20) { + // Off + TimerCard { + TimerOptionRow( + label: "Off", systemImage: "moon.zzz", + isSelected: audioPlayer.sleepTimer == nil + ) { + audioPlayer.setSleepTimer(nil) + dismiss() + } + } + + // Chapter-based + VStack(spacing: 0) { + SectionLabel("Chapter-based") + TimerCard { + ForEach([1, 2, 3, 4], id: \.self) { count in + let isSelected: Bool = { + if case .chapters(let c) = audioPlayer.sleepTimer { return c == count } + return false + }() + TimerOptionRow( + label: "\(count) \(count == 1 ? "chapter" : "chapters")", + systemImage: "book", isSelected: isSelected + ) { + audioPlayer.setSleepTimer(.chapters(count)) + dismiss() + } + if count < 4 { Divider().padding(.leading, 56) } + } + } + } + + // Time-based + VStack(spacing: 0) { + SectionLabel("Time-based") + TimerCard { + ForEach([20, 40, 60, 120], id: \.self) { mins in + let isSelected: Bool = { + if case .minutes(let m) = audioPlayer.sleepTimer { return m == mins } + return false + }() + TimerOptionRow( + label: formatTimerOption(mins), systemImage: "clock", + isSelected: isSelected + ) { + audioPlayer.setSleepTimer(.minutes(mins)) + dismiss() + } + if mins != 120 { Divider().padding(.leading, 56) } + } + } + } + } + .padding(20) + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("Sleep Timer") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() }.fontWeight(.semibold) + } + } + } + } + + private func formatTimerOption(_ minutes: Int) -> String { + if minutes < 60 { return "\(minutes) mins" } + let h = minutes / 60 + return "\(h) \(h == 1 ? "hour" : "hours")" + } +} + +// MARK: - Sleep timer helper views + +private struct TimerCard: View { + @ViewBuilder let content: Content + var body: some View { + VStack(spacing: 0) { content } + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} + +private struct SectionLabel: View { + let text: String + init(_ text: String) { self.text = text } + var body: some View { + Text(text.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 4) + .padding(.bottom, 8) + } +} + +private struct TimerOptionRow: View { + let label: String + let systemImage: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + action() + } label: { + HStack(spacing: 14) { + Image(systemName: systemImage) + .font(.system(size: 16)) + .foregroundStyle(isSelected ? Color.amber : .secondary) + .frame(width: 28) + Text(label) + .font(.body) + .foregroundStyle(.primary) + Spacer() + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.amber) + .transition(.scale.combined(with: .opacity)) + } + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected) + } +} + +// MARK: - Player Chapters List Sheet +// Groups chapters into blocks of 100 with a right-edge jump bar. +// Includes per-chapter download status and swipe actions. + +struct PlayerChaptersListSheet: View { + let chapters: [ChapterBrief] + let currentChapter: Int + let onChapterSelect: (Int) -> Void + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var downloadService: AudioDownloadService + + @State private var searchText: String = "" + @State private var filterOfflineOnly = false + @State private var showingManage = false + @State private var activeBlock: String? = nil + + // MARK: Derived data + + private var downloadedCount: Int { + chapters.filter { + downloadService.isDownloaded(slug: audioPlayer.slug, chapter: $0.number, voice: audioPlayer.voice) + }.count + } + + private var downloadingCount: Int { + downloadService.downloads.filter { key, _ in key.hasPrefix("\(audioPlayer.slug)::") }.count + } + + private var filtered: [ChapterBrief] { + var result = chapters + if filterOfflineOnly { + result = result.filter { + downloadService.isDownloaded(slug: audioPlayer.slug, chapter: $0.number, voice: audioPlayer.voice) + } + } + if !searchText.isEmpty { + let q = searchText.lowercased() + result = result.filter { "\($0.number)".contains(q) || $0.title.lowercased().contains(q) } + } + return result + } + + private var groups: [(label: String, chapters: [ChapterBrief])] { + guard searchText.isEmpty && !filterOfflineOnly else { + return filtered.isEmpty ? [] : [("Results", filtered)] + } + guard !filtered.isEmpty else { return [] } + let blockSize = 100 + let minN = filtered.map(\.number).min() ?? 1 + let maxN = filtered.map(\.number).max() ?? 1 + let firstBlock = ((minN - 1) / blockSize) * blockSize + 1 + var result: [(label: String, chapters: [ChapterBrief])] = [] + var blockStart = firstBlock + while blockStart <= maxN { + let blockEnd = blockStart + blockSize - 1 + let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd } + if !slice.isEmpty { result.append(("\(blockStart)–\(blockEnd)", slice)) } + blockStart += blockSize + } + return result + } + + private var jumpLabels: [String] { groups.map(\.label) } + + var body: some View { + NavigationStack { + ZStack(alignment: .trailing) { + List { + // Download summary + if downloadedCount > 0 || downloadingCount > 0 { + Section { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Offline Downloads").font(.headline) + Text("\(downloadedCount) of \(chapters.count) chapters") + .font(.subheadline).foregroundStyle(.secondary) + } + Spacer() + Button { showingManage = true } label: { + Label("Manage", systemImage: "arrow.down.circle") + .font(.subheadline.weight(.semibold)) + } + .buttonStyle(.bordered).tint(.blue) + } + if downloadingCount > 0 { + HStack(spacing: 8) { + ProgressView().scaleEffect(0.8) + Text("Downloading \(downloadingCount) \(downloadingCount == 1 ? "chapter" : "chapters")") + .font(.caption).foregroundStyle(.secondary) + } + } + Toggle("Show offline only", isOn: $filterOfflineOnly) + .font(.subheadline).tint(Color.amber) + } + .padding(.vertical, 8) + } + } + + ForEach(groups, id: \.label) { group in + Section { + ForEach(group.chapters, id: \.number) { ch in + PlayerChapterRow( + chapter: ch, + isCurrent: ch.number == currentChapter, + onSelect: { onChapterSelect(ch.number) } + ) + .id(group.label) + } + } header: { + if searchText.isEmpty && !filterOfflineOnly { + Text(group.label) + .font(.caption.bold()) + .foregroundStyle(.secondary) + .id("header_\(group.label)") + } + } + } + } + .listStyle(.plain) + .searchable(text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Chapter number or title") + .scrollPosition(id: $activeBlock, anchor: .top) + + // Jump bar + if searchText.isEmpty && !filterOfflineOnly && jumpLabels.count > 1 { + PlayerJumpBar(labels: jumpLabels, currentChapter: currentChapter, groups: groups) { label in + withAnimation { activeBlock = label } + } + .padding(.trailing, 4) + } + } + .navigationTitle("Chapters (\(filtered.count))") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() }.fontWeight(.semibold) + } + } + .sheet(isPresented: $showingManage) { + DownloadManagementSheet( + chapters: chapters, slug: audioPlayer.slug, + voice: Binding(get: { audioPlayer.voice }, set: { audioPlayer.voice = $0 }) + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + .onAppear { + if let block = groups.first(where: { g in + g.chapters.contains(where: { $0.number == currentChapter }) + }) { + activeBlock = block.label + } + } + } + } +} + +// MARK: - Individual chapter row (player chapters list) + +private struct PlayerChapterRow: View { + let chapter: ChapterBrief + let isCurrent: Bool + let onSelect: () -> Void + + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var downloadService: AudioDownloadService + + private var isDownloaded: Bool { + downloadService.isDownloaded(slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) + } + private var downloadProgress: DownloadProgress? { + let key = downloadService.makeKey(slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) + return downloadService.downloads[key] + } + private var isDownloading: Bool { downloadProgress != nil } + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 14) { + // Number badge + ZStack { + Text("\(chapter.number)") + .font(.caption.bold()) + .foregroundStyle(isCurrent ? .white : .secondary) + .frame(width: 40, height: 40) + .background(Circle().fill(isCurrent ? Color.amber : Color(.systemGray5))) + + if isDownloading, let p = downloadProgress { + Circle() + .trim(from: 0, to: p.progress) + .stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .frame(width: 44, height: 44) + .animation(.easeInOut(duration: 0.3), value: p.progress) + } + } + + // Title + status + VStack(alignment: .leading, spacing: 3) { + Text(chapter.title.strippingTrailingDate()) + .font(.subheadline.weight(isCurrent ? .semibold : .regular)) + .foregroundStyle(.primary) + .lineLimit(2) + + HStack(spacing: 8) { + if isCurrent { + Label("Now Playing", systemImage: "waveform") + .font(.caption2) + .foregroundStyle(Color.amber) + .symbolEffect(.variableColor.cumulative, isActive: isCurrent) + } + if isDownloading, let p = downloadProgress { + Label("\(Int(p.progress * 100))%", systemImage: "arrow.down.circle") + .font(.caption2).foregroundStyle(.blue) + } else if isDownloaded { + Label("Downloaded", systemImage: "checkmark.circle.fill") + .font(.caption2).foregroundStyle(.green) + } + } + } + + Spacer() + + if isCurrent { + Image(systemName: "waveform") + .font(.caption.bold()) + .foregroundStyle(Color.amber) + .symbolEffect(.variableColor.cumulative, isActive: isCurrent) + } else if isDownloaded { + Image(systemName: "arrow.down.circle.fill") + .font(.body).foregroundStyle(.green) + } else if isDownloading { + ProgressView().scaleEffect(0.8) + } + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if isDownloaded { + Button(role: .destructive) { + try? downloadService.deleteDownload( + slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) + } label: { Label("Delete", systemImage: "trash") } + } else if isDownloading { + Button(role: .destructive) { + downloadService.cancelDownload( + slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) + } label: { Label("Cancel", systemImage: "xmark") } + } else { + Button { + Task { + try? await downloadService.download( + slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) + } + } label: { Label("Download", systemImage: "arrow.down.circle") } + .tint(.blue) + } + } + } +} + +// MARK: - Jump bar (right edge) + +private struct PlayerJumpBar: View { + let labels: [String] + let currentChapter: Int + let groups: [(label: String, chapters: [ChapterBrief])] + let onSelect: (String) -> Void + + @State private var isDragging = false + + private func shortLabel(_ full: String) -> String { + full.components(separatedBy: "–").first ?? full + } + + private var currentBlock: String? { + groups.first(where: { $0.chapters.contains(where: { $0.number == currentChapter }) })?.label + } + + var body: some View { + VStack(spacing: 0) { + ForEach(labels, id: \.self) { label in + let isCurrent = label == currentBlock + Text(shortLabel(label)) + .font(.system(size: 10, weight: isCurrent ? .bold : .regular)) + .foregroundStyle(isCurrent ? Color.amber : Color.secondary) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + .onTapGesture { onSelect(label) } + } + } + .padding(.vertical, 6) + .background(Capsule().fill(.ultraThinMaterial).shadow(color: .black.opacity(0.15), radius: 4)) + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .local) + .onChanged { value in + isDragging = true + let index = max(0, min(labels.count - 1, Int(value.location.y / 28))) + onSelect(labels[index]) + } + .onEnded { _ in isDragging = false } + ) + .animation(.easeInOut(duration: 0.15), value: isDragging) + } +} + +// MARK: - Voice selector panel (inline, expandable inside FullPlayerView) + +private struct VoiceSelectorPanel: View { + let voiceVM: VoiceSelectionViewModel + let selectedVoice: String + let onSelectVoice: (String) -> Void + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Choose Voice") + .font(.caption.weight(.semibold)) + .foregroundStyle(.white.opacity(0.45)) + .textCase(.uppercase) + .tracking(0.8) + Spacer() + } + .padding(.horizontal, 18) + .padding(.top, 10) + .padding(.bottom, 6) + + ScrollView { + VStack(spacing: 0) { + ForEach(voiceVM.voices, id: \.self) { voice in + VoiceOptionRow( + voice: voice, + isSelected: selectedVoice == voice, + isPlaying: voiceVM.playingVoice == voice, + voiceLabel: voiceVM.voiceLabel(voice), + voiceId: voiceVM.voiceId(voice), + onSelect: { onSelectVoice(voice) }, + onPlaySample: { Task { await voiceVM.playSample(voice) } } + ) + if voice != voiceVM.voices.last { + Divider().overlay(Color.white.opacity(0.08)).padding(.leading, 52) + } + } + } + } + .frame(maxHeight: 220) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .padding(.horizontal, 16) + + Text("New voice applies on next chapter") + .font(.caption2) + .foregroundStyle(.white.opacity(0.35)) + .padding(.top, 7) + .padding(.bottom, 10) + } + .background(.ultraThinMaterial) + } +} + +// MARK: - Voice option row (inside VoiceSelectorPanel) + +private struct VoiceOptionRow: View { + let voice: String + let isSelected: Bool + let isPlaying: Bool + let voiceLabel: String + let voiceId: String + let onSelect: () -> Void + let onPlaySample: () -> Void + + var body: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + onSelect() + } label: { + HStack(spacing: 12) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 18)) + .foregroundStyle(isSelected ? Color.amber : .white.opacity(0.25)) + .scaleEffect(isSelected ? 1.1 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.55), value: isSelected) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 2) { + Text(voiceLabel) + .font(.subheadline) + .foregroundStyle(isSelected ? Color.amber : .white) + .fontWeight(isSelected ? .semibold : .regular) + .animation(.easeInOut(duration: 0.2), value: isSelected) + Text(voiceId) + .font(.caption2) + .fontDesign(.monospaced) + .foregroundStyle(.white.opacity(0.4)) + } + + Spacer() + + Button { onPlaySample() } label: { + Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill") + .font(.system(size: 24)) + .foregroundStyle(isPlaying ? Color.red : Color.amber.opacity(0.8)) + .contentTransition(.symbolEffect(.replace.downUp)) + .frame(minWidth: 44, minHeight: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(isPlaying ? "Stop sample" : "Play sample") + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(isSelected ? Color.amber.opacity(0.08) : Color.clear) + .animation(.easeInOut(duration: 0.2), value: isSelected) + } +} + +// MARK: - Download Management Sheet + +struct DownloadManagementSheet: View { + let chapters: [ChapterBrief] + let slug: String + @Binding var voice: String + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var downloadService: AudioDownloadService + @EnvironmentObject var authStore: AuthStore + + @State private var showingDeleteAll = false + @State private var isDownloadingAll = false + @State private var showingVoiceSelector = false + @State private var showingRangeSelector = false + @State private var voiceVM = VoiceSelectionViewModel() + + private var downloadedChapters: [ChapterBrief] { + chapters.filter { downloadService.isDownloaded(slug: slug, chapter: $0.number, voice: voice) } + } + private var notDownloadedChapters: [ChapterBrief] { + chapters.filter { !downloadService.isDownloaded(slug: slug, chapter: $0.number, voice: voice) } + } + + var body: some View { + NavigationStack { + List { + // Voice info + Section { + Button { showingVoiceSelector = true } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Download Voice").font(.subheadline).foregroundStyle(.secondary) + HStack(spacing: 6) { + Text(voiceLabel(voice)).font(.body.weight(.semibold)) + if BookVoicePreferences.shared.hasOverride(for: slug) { + Text("(Custom)").font(.caption).foregroundStyle(.blue) + } + } + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)).foregroundStyle(.tertiary) + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + } footer: { + Text("Tap to change voice. Downloads will use the selected voice for this book.") + .font(.caption) + } + + // Stats + actions + Section { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("\(downloadedChapters.count) Downloaded").font(.title2.bold()) + Text("\(notDownloadedChapters.count) remaining") + .font(.subheadline).foregroundStyle(.secondary) + } + Spacer() + ZStack { + Circle().stroke(Color(.systemGray5), lineWidth: 4) + Circle() + .trim(from: 0, to: chapters.isEmpty ? 0 : CGFloat(downloadedChapters.count) / CGFloat(chapters.count)) + .stroke(Color.green, style: StrokeStyle(lineWidth: 4, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.4), value: downloadedChapters.count) + Text("\(chapters.isEmpty ? 0 : Int(Double(downloadedChapters.count) / Double(chapters.count) * 100))%") + .font(.caption2.bold()).foregroundStyle(.secondary) + } + .frame(width: 44, height: 44) + } + + HStack(spacing: 10) { + if notDownloadedChapters.count > 0 { + Button { showingRangeSelector = true } label: { + Label("Range", systemImage: "list.number").frame(maxWidth: .infinity) + } + .buttonStyle(.bordered).tint(.blue) + + Button { downloadAllRemaining() } label: { + HStack(spacing: 6) { + if isDownloadingAll { ProgressView().scaleEffect(0.75) } + else { Image(systemName: "arrow.down.circle.fill") } + Text("All (\(notDownloadedChapters.count))") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent).tint(.blue) + .disabled(isDownloadingAll) + } + + if downloadedChapters.count > 0 { + Button { showingDeleteAll = true } label: { + Label("Delete All", systemImage: "trash").frame(maxWidth: .infinity) + } + .buttonStyle(.bordered).tint(.red) + } + } + } + .padding(.vertical, 8) + } + + // Downloaded list + if downloadedChapters.count > 0 { + Section { + ForEach(downloadedChapters, id: \.number) { ch in + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Chapter \(ch.number)").font(.subheadline.weight(.semibold)) + Text(ch.title.strippingTrailingDate()) + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + } + } + .onDelete { indexSet in + for i in indexSet { + let ch = downloadedChapters[i] + try? downloadService.deleteDownload(slug: slug, chapter: ch.number, voice: voice) + } + } + } header: { + Text("Downloaded (\(downloadedChapters.count))") + } + } + } + .navigationTitle("Manage Downloads") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() }.fontWeight(.semibold) + } + } + .confirmationDialog("Delete all downloads?", isPresented: $showingDeleteAll, titleVisibility: .visible) { + Button("Delete All Downloads", role: .destructive) { + for ch in downloadedChapters { + try? downloadService.deleteDownload(slug: slug, chapter: ch.number, voice: voice) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will delete \(downloadedChapters.count) downloaded chapters. You can re-download them later.") + } + .sheet(isPresented: $showingVoiceSelector) { + VoiceSelectorSheet( + selectedVoice: voice, slug: slug, voiceVM: voiceVM, + onSelectVoice: { newVoice in + voice = newVoice + BookVoicePreferences.shared.setVoice(newVoice, for: slug) + showingVoiceSelector = false + } + ) + } + .sheet(isPresented: $showingRangeSelector) { + RangeDownloadSheet( + chapters: notDownloadedChapters, slug: slug, voice: voice, + onDownload: { start, end in + downloadRange(from: start, to: end) + showingRangeSelector = false + } + ) + .presentationDetents([.medium]) + } + } + } + + private func downloadAllRemaining() { + isDownloadingAll = true + Task { + for ch in notDownloadedChapters { + try? await downloadService.download(slug: slug, chapter: ch.number, voice: voice) + try? await Task.sleep(nanoseconds: 500_000_000) + } + isDownloadingAll = false + } + } + + private func downloadRange(from start: Int, to end: Int) { + isDownloadingAll = true + Task { + let toDownload = notDownloadedChapters.filter { $0.number >= start && $0.number <= end } + for ch in toDownload { + try? await downloadService.download(slug: slug, chapter: ch.number, voice: voice) + try? await Task.sleep(nanoseconds: 500_000_000) + } + isDownloadingAll = false + } + } + + private func voiceLabel(_ voice: String) -> String { + let parts = voice.split(separator: "_") + guard parts.count >= 2 else { return voice } + let prefix = String(parts[0]) + let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ") + var info = "" + switch prefix { + case "af": info = "US F"; case "am": info = "US M" + case "bf": info = "UK F"; case "bm": info = "UK M" + default: info = prefix.uppercased() + } + return "\(name) (\(info))" + } +} + +// MARK: - Voice Selector Sheet (for DownloadManagementSheet) + +private struct VoiceSelectorSheet: View { + let selectedVoice: String + let slug: String + let voiceVM: VoiceSelectionViewModel + let onSelectVoice: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var authStore: AuthStore + + var body: some View { + NavigationStack { + List { + Section { + ForEach(voiceVM.voices, id: \.self) { voice in + Button { onSelectVoice(voice) } label: { + HStack(spacing: 12) { + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.blue) + .opacity(voice == selectedVoice ? 1 : 0) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(voiceVM.voiceLabel(voice)).font(.body).foregroundStyle(.primary) + Text(voice).font(.caption.monospaced()).foregroundStyle(.secondary) + } + + Spacer() + + Button { + Task { await voiceVM.playSample(voice) } + } label: { + Image(systemName: voiceVM.playingVoice == voice ? "stop.circle.fill" : "play.circle") + .font(.system(size: 24)) + .foregroundStyle(voiceVM.playingVoice == voice ? .red : .blue) + .frame(minWidth: 44, minHeight: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(voiceVM.playingVoice == voice ? "Stop sample" : "Play sample") + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + } + } header: { + Text("Select Voice") + } footer: { + if BookVoicePreferences.shared.hasOverride(for: slug) { + Button("Reset to Global Voice") { + BookVoicePreferences.shared.removeVoice(for: slug) + onSelectVoice(authStore.settings.voice) + } + .font(.subheadline) + } + } + } + .navigationTitle("Download Voice") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { voiceVM.stopSample(); dismiss() }.fontWeight(.semibold) + } + } + .task { + if voiceVM.voices.isEmpty { await voiceVM.loadVoices() } + } + } + } +} + +// MARK: - Range Download Sheet + +private struct RangeDownloadSheet: View { + let chapters: [ChapterBrief] + let slug: String + let voice: String + let onDownload: (Int, Int) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var startChapter: Int + @State private var endChapter: Int + + init(chapters: [ChapterBrief], slug: String, voice: String, onDownload: @escaping (Int, Int) -> Void) { + self.chapters = chapters + self.slug = slug + self.voice = voice + self.onDownload = onDownload + let first = chapters.first?.number ?? 1 + let last = chapters.last?.number ?? 1 + _startChapter = State(initialValue: first) + _endChapter = State(initialValue: min(first + 9, last)) + } + + private var chapterRange: [Int] { + guard let first = chapters.first?.number, let last = chapters.last?.number else { return [] } + return Array(first...last) + } + private var selectedCount: Int { + guard startChapter <= endChapter else { return 0 } + return endChapter - startChapter + 1 + } + + var body: some View { + NavigationStack { + Form { + Section { + Picker("Start Chapter", selection: $startChapter) { + ForEach(chapterRange, id: \.self) { n in Text("Chapter \(n)").tag(n) } + } + Picker("End Chapter", selection: $endChapter) { + ForEach(chapterRange.filter { $0 >= startChapter }, id: \.self) { n in + Text("Chapter \(n)").tag(n) + } + } + } header: { Text("Select Range") } + footer: { Text("\(selectedCount) chapters will be downloaded") } + + Section { + Button { + onDownload(startChapter, endChapter) + dismiss() + } label: { + HStack { + Spacer() + Image(systemName: "arrow.down.circle.fill") + Text("Download \(selectedCount) Chapters") + Spacer() + } + } + .disabled(selectedCount == 0) + } + } + .navigationTitle("Download Range") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Cancel") { dismiss() } + } + } + } + } +} diff --git a/ios/LibNovelV2/Views/Profile/ProfileView.swift b/ios/LibNovelV2/Views/Profile/ProfileView.swift new file mode 100644 index 0000000..df6757e --- /dev/null +++ b/ios/LibNovelV2/Views/Profile/ProfileView.swift @@ -0,0 +1,702 @@ +import SwiftUI +import PhotosUI + +// MARK: - ProfileViewModel +// Loads and manages active sessions. Uses @Observable (iOS 17+). + +@Observable @MainActor +final class ProfileViewModel { + var sessions: [UserSession] = [] + var sessionsLoading = false + var error: String? + + func loadSessions() async { + sessionsLoading = true + error = nil + do { + sessions = try await APIClient.shared.sessions() + } catch { + self.error = error.localizedDescription + } + sessionsLoading = false + } + + func revokeSession(id: String) async { + do { + try await APIClient.shared.revokeSession(id: id) + sessions.removeAll { $0.id == id } + } catch { + self.error = error.localizedDescription + } + } +} + +// MARK: - ProfileView +// Full-screen profile/account management tab. + +struct ProfileView: View { + @EnvironmentObject private var authStore: AuthStore + @EnvironmentObject private var networkMonitor: NetworkMonitor + @State private var vm = ProfileViewModel() + + @State private var showChangePassword = false + @State private var showVoiceSelection = false + @State private var showDownloads = false + + // Avatar upload + @State private var photoPickerItem: PhotosPickerItem? + @State private var pendingCropImage: UIImage? + @State private var localAvatarURL: String? + @State private var avatarUploading = false + @State private var avatarError: String? + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + OfflineBanner() + + List { + // ── User header ───────────────────────────────────────── + Section { + HStack(spacing: 16) { + avatarPickerView + VStack(alignment: .leading, spacing: 3) { + Text(authStore.user?.username ?? "") + .font(.headline) + Text(authStore.user?.role.capitalized ?? "") + .font(.caption) + .foregroundStyle(.secondary) + if let err = avatarError { + Text(err) + .font(.caption2) + .foregroundStyle(.red) + } + } + } + .padding(.vertical, 6) + } + + // ── Reading settings ───────────────────────────────────── + Section("Reading Settings") { + // Voice picker row — opens VoiceSelectionView sheet + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showVoiceSelection = true + } label: { + HStack { + Text("TTS Voice") + .foregroundStyle(.primary) + Spacer() + Text(formatVoiceLabel(authStore.settings.voice)) + .foregroundStyle(.secondary) + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .accessibilityLabel("TTS Voice: \(formatVoiceLabel(authStore.settings.voice)). Tap to change.") + + // Speed slider + speedSliderRow + + // Auto-advance toggle + Toggle("Auto-advance chapter", isOn: Binding( + get: { authStore.settings.autoNext }, + set: { newVal in + Task { + var s = authStore.settings + s.autoNext = newVal + await authStore.saveSettings(s) + } + } + )) + .tint(Color.amber) + + // Downloads row + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showDownloads = true + } label: { + HStack { + Text("Downloads") + .foregroundStyle(.primary) + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + + // ── Active sessions ────────────────────────────────────── + Section("Active Sessions") { + if vm.sessionsLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 4) + } else if vm.sessions.isEmpty { + Text("No sessions found") + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + ForEach(vm.sessions) { session in + SessionRow(session: session) { + Task { await vm.revokeSession(id: session.id) } + } + } + } + } + + // ── Account ─────────────────────────────────────────────── + Section("Account") { + Button("Change Password") { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showChangePassword = true + } + Button("Sign Out", role: .destructive) { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + Task { await authStore.logout() } + } + } + } + .scrollContentBackground(.hidden) + } + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .navigationTitle("Profile") + .navigationBarTitleDisplayMode(.large) + .task { + guard networkMonitor.isConnected else { return } + await vm.loadSessions() + } + .sheet(isPresented: $showChangePassword) { + ChangePasswordView() + } + .sheet(isPresented: $showVoiceSelection) { + VoiceSelectionView(currentVoice: authStore.settings.voice) + } + .sheet(isPresented: $showDownloads) { + DownloadsView() + } + .sheet(item: Binding( + get: { pendingCropImage.map { CropImageItem(image: $0) } }, + set: { if $0 == nil { pendingCropImage = nil } } + )) { item in + AvatarCropView(image: item.image) { croppedData in + pendingCropImage = nil + Task { await uploadCroppedData(croppedData) } + } onCancel: { + pendingCropImage = nil + } + } + .errorAlert(Binding( + get: { vm.error }, + set: { vm.error = $0 } + )) + } + } + + // MARK: - Avatar upload + + private func loadImageForCrop(_ item: PhotosPickerItem) async { + guard let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) else { + avatarError = "Could not read image" + return + } + pendingCropImage = image + } + + private func uploadCroppedData(_ data: Data) async { + avatarUploading = true + avatarError = nil + defer { avatarUploading = false } + do { + let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg") + localAvatarURL = url + await authStore.validateToken() + } catch { + avatarError = "Upload failed: \(error.localizedDescription)" + } + } + + // MARK: - Avatar picker view + + @ViewBuilder + private var avatarPickerView: some View { + PhotosPicker(selection: $photoPickerItem, + matching: .images, + photoLibrary: .shared()) { + ZStack { + Circle() + .fill(Color(uiColor: .systemGray5)) + .frame(width: 72, height: 72) + + if avatarUploading { + ProgressView() + .frame(width: 72, height: 72) + } else { + let urlStr = localAvatarURL ?? authStore.user?.avatarURL + if let urlStr, !urlStr.isEmpty { + AsyncImage(url: URL(string: urlStr)) { phase in + switch phase { + case .success(let img): + img.resizable() + .scaledToFill() + .frame(width: 72, height: 72) + .clipShape(Circle()) + default: + Image(systemName: "person.circle.fill") + .font(.system(size: 52)) + .foregroundStyle(Color.amber) + .frame(width: 72, height: 72) + } + } + } else { + Image(systemName: "person.circle.fill") + .font(.system(size: 52)) + .foregroundStyle(Color.amber) + .frame(width: 72, height: 72) + } + } + + // Camera badge + if !avatarUploading { + VStack { + Spacer() + HStack { + Spacer() + ZStack { + Circle() + .fill(Color.amber) + .frame(width: 22, height: 22) + Image(systemName: "camera.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.black) + } + .offset(x: 2, y: 2) + } + } + .frame(width: 72, height: 72) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("Change avatar photo") + .onChange(of: photoPickerItem) { _, item in + guard let item else { return } + Task { await loadImageForCrop(item) } + } + } + + // MARK: - Speed slider row + + @ViewBuilder + private var speedSliderRow: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Playback Speed") + Spacer() + Text("\(authStore.settings.speed, specifier: "%.2g")×") + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider( + value: Binding( + get: { authStore.settings.speed }, + set: { newSpeed in + Task { + var s = authStore.settings + s.speed = newSpeed + await authStore.saveSettings(s) + } + } + ), + in: 0.5...2.0, step: 0.25 + ) + .tint(Color.amber) + } + .padding(.vertical, 2) + } + + // MARK: - Helpers + + private func formatVoiceLabel(_ voice: String) -> String { + let parts = voice.split(separator: "_") + guard parts.count >= 2 else { return voice } + return parts.dropFirst().map { $0.capitalized }.joined(separator: " ") + } +} + +// MARK: - SessionRow + +private struct SessionRow: View { + let session: UserSession + let onRevoke: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Image(systemName: "iphone") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent) + .font(.subheadline) + .lineLimit(1) + Spacer() + if session.isCurrent { + Text("This device") + .font(.caption2.bold()) + .foregroundStyle(Color.amber) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.amber.opacity(0.12), in: Capsule()) + } else { + Button("Revoke", role: .destructive, action: onRevoke) + .font(.caption) + } + } + Text("Last seen \(session.lastSeen.prefix(10))") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } +} + +// MARK: - CropImageItem + +private struct CropImageItem: Identifiable { + let id = UUID() + let image: UIImage +} + +// MARK: - ChangePasswordView + +struct ChangePasswordView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var authStore: AuthStore + + @State private var current = "" + @State private var newPwd = "" + @State private var confirm = "" + @State private var isLoading = false + @State private var error: String? + @State private var success = false + + var body: some View { + NavigationStack { + Form { + Section { + SecureField("Current password", text: $current) + SecureField("New password", text: $newPwd) + SecureField("Confirm new password", text: $confirm) + } + if let error { + Section { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + if success { + Section { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Password changed successfully") + .font(.caption) + .foregroundStyle(.green) + } + } + } + } + .navigationTitle("Change Password") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + if isLoading { + ProgressView() + } else { + Button("Save") { save() } + .fontWeight(.semibold) + .foregroundStyle(Color.amber) + .disabled(current.isEmpty || newPwd.count < 4 || newPwd != confirm) + } + } + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + + private func save() { + guard newPwd == confirm else { error = "Passwords do not match"; return } + isLoading = true + error = nil + Task { + do { + struct Body: Encodable { let currentPassword, newPassword: String } + let _: EmptyResponse = try await APIClient.shared.fetch( + "/api/auth/change-password", method: "POST", + body: Body(currentPassword: current, newPassword: newPwd) + ) + success = true + try? await Task.sleep(nanoseconds: 1_200_000_000) + dismiss() + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + } +} + +// MARK: - AvatarToolbarButton +// Drop-in toolbar button showing the user's avatar. Opens the profile tab or an account sheet. + +struct AvatarToolbarButton: View { + @EnvironmentObject private var authStore: AuthStore + @State private var showAccount = false + + var body: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showAccount = true + } label: { + AvatarThumb(urlString: authStore.user?.avatarURL, size: 30) + } + .accessibilityLabel("Account") + .sheet(isPresented: $showAccount) { + ProfileView() + } + } +} + +// MARK: - AvatarThumb +// Small circular avatar used in toolbars and list headers. + +struct AvatarThumb: View { + let urlString: String? + let size: CGFloat + + var body: some View { + Group { + if let str = urlString, let url = URL(string: str) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let img): + img.resizable().scaledToFill() + default: + placeholderFill + } + } + } else { + placeholderFill + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.amber.opacity(0.6), lineWidth: 1.5)) + } + + private var placeholderFill: some View { + Circle() + .fill(Color(uiColor: .systemGray4)) + .overlay( + Image(systemName: "person.fill") + .font(.system(size: size * 0.5)) + .foregroundStyle(Color.amber) + ) + } +} + +// MARK: - AvatarCropView +// Sheet that lets the user pan and pinch a photo to fill a 1:1 circular crop region. + +struct AvatarCropView: View { + let image: UIImage + let onConfirm: (Data) -> Void + let onCancel: () -> Void + + private let cropSize: CGFloat = 280 + + @State private var scale: CGFloat = 1.0 + @State private var lastScale: CGFloat = 1.0 + @State private var offset: CGSize = .zero + @State private var lastOffset: CGSize = .zero + @State private var containerSize: CGSize = .zero + + var body: some View { + NavigationStack { + GeometryReader { geo in + ZStack { + Color.black.ignoresSafeArea() + + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: geo.size.width, height: geo.size.height) + .scaleEffect(scale, anchor: .center) + .offset(offset) + .gesture( + SimultaneousGesture( + MagnificationGesture() + .onChanged { value in + let proposed = lastScale * value + scale = max(1.0, proposed) + } + .onEnded { _ in + lastScale = scale + offset = clampedOffset(offset, in: geo.size) + lastOffset = offset + }, + DragGesture() + .onChanged { value in + let proposed = CGSize( + width: lastOffset.width + value.translation.width, + height: lastOffset.height + value.translation.height + ) + offset = clampedOffset(proposed, in: geo.size) + } + .onEnded { _ in lastOffset = offset } + ) + ) + .clipped() + + CropOverlay(cropSize: cropSize, containerSize: geo.size) + .allowsHitTesting(false) + } + .onAppear { + containerSize = geo.size + scale = 1.0; lastScale = 1.0 + offset = .zero; lastOffset = .zero + } + } + .navigationTitle("Crop Photo") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel", action: onCancel) + .foregroundStyle(.white) + } + ToolbarItem(placement: .topBarTrailing) { + Button("Use Photo") { confirmCrop() } + .fontWeight(.semibold) + .foregroundStyle(Color.amber) + } + } + .toolbarColorScheme(.dark, for: .navigationBar) + } + } + + // MARK: - Clamp helpers + + private func displayedImageSize(in containerSize: CGSize, userScale: CGFloat) -> CGSize { + let imgAspect = image.size.width / image.size.height + let conAspect = containerSize.width / containerSize.height + let baseW: CGFloat + let baseH: CGFloat + if imgAspect > conAspect { + baseH = containerSize.height; baseW = baseH * imgAspect + } else { + baseW = containerSize.width; baseH = baseW / imgAspect + } + return CGSize(width: baseW * userScale, height: baseH * userScale) + } + + private func clampedOffset(_ proposed: CGSize, in containerSize: CGSize) -> CGSize { + let displayed = displayedImageSize(in: containerSize, userScale: scale) + let maxX = max(0, (displayed.width - cropSize) / 2) + let maxY = max(0, (displayed.height - cropSize) / 2) + return CGSize( + width: min(maxX, max(-maxX, proposed.width)), + height: min(maxY, max(-maxY, proposed.height)) + ) + } + + // MARK: - Confirm crop + + private func confirmCrop() { + let size = containerSize.width > 0 ? containerSize : CGSize(width: 390, height: 844) + let outputSize = CGSize(width: 400, height: 400) + + let imgAspect = image.size.width / image.size.height + let conAspect = size.width / size.height + let baseDisplayW: CGFloat + let baseDisplayH: CGFloat + if imgAspect > conAspect { + baseDisplayH = size.height; baseDisplayW = baseDisplayH * imgAspect + } else { + baseDisplayW = size.width; baseDisplayH = baseDisplayW / imgAspect + } + let displayW = baseDisplayW * scale + let displayH = baseDisplayH * scale + + let imageCentreX = size.width / 2 + offset.width + let imageCentreY = size.height / 2 + offset.height + let cropOriginX = (size.width - cropSize) / 2 + let cropOriginY = (size.height - cropSize) / 2 + let imageOriginX = imageCentreX - displayW / 2 + let imageOriginY = imageCentreY - displayH / 2 + let cropInImageX = cropOriginX - imageOriginX + let cropInImageY = cropOriginY - imageOriginY + + let dtpX = image.size.width / displayW + let dtpY = image.size.height / displayH + let cropRect = CGRect( + x: cropInImageX * dtpX, y: cropInImageY * dtpY, + width: cropSize * dtpX, height: cropSize * dtpY + ).intersection(CGRect(origin: .zero, size: image.size)) + + guard cropRect.width > 0, cropRect.height > 0 else { + if let jpeg = image.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) } + return + } + + let renderer = UIGraphicsImageRenderer(size: outputSize) + let cropped = renderer.image { _ in + if let cgImg = image.cgImage?.cropping(to: cropRect) { + UIImage(cgImage: cgImg, scale: image.scale, + orientation: image.imageOrientation) + .draw(in: CGRect(origin: .zero, size: outputSize)) + } else { + image.draw(in: CGRect(origin: .zero, size: outputSize)) + } + } + if let jpeg = cropped.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) } + } +} + +// MARK: - CropOverlay (internal) + +private struct CropOverlay: View { + let cropSize: CGFloat + let containerSize: CGSize + + var body: some View { + Canvas { context, size in + context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55))) + let origin = CGPoint(x: (size.width - cropSize) / 2, y: (size.height - cropSize) / 2) + let rect = CGRect(origin: origin, size: CGSize(width: cropSize, height: cropSize)) + context.blendMode = .destinationOut + context.fill(Path(ellipseIn: rect), with: .color(.white)) + } + .compositingGroup() + .overlay { + let ox = (containerSize.width - cropSize) / 2 + let oy = (containerSize.height - cropSize) / 2 + Circle() + .stroke(Color.amber.opacity(0.8), lineWidth: 2) + .frame(width: cropSize, height: cropSize) + .position(x: ox + cropSize / 2, y: oy + cropSize / 2) + } + .frame(width: containerSize.width, height: containerSize.height) + .allowsHitTesting(false) + } +} diff --git a/ios/LibNovelV2/Views/Profile/UserProfileView.swift b/ios/LibNovelV2/Views/Profile/UserProfileView.swift new file mode 100644 index 0000000..60c3e07 --- /dev/null +++ b/ios/LibNovelV2/Views/Profile/UserProfileView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +// Public user profile — shown when navigating to another user's page. +// Displays their public library and follower info. +// NOTE: This is distinct from ProfileView (self-account management tab). +struct UserProfileView: View { + let username: String + + var body: some View { + Text(username) + .navigationTitle(username) + } +} diff --git a/ios/LibNovelV2/Views/Profile/VoiceSelectionView.swift b/ios/LibNovelV2/Views/Profile/VoiceSelectionView.swift new file mode 100644 index 0000000..96e6b8f --- /dev/null +++ b/ios/LibNovelV2/Views/Profile/VoiceSelectionView.swift @@ -0,0 +1,189 @@ +import SwiftUI + +// MARK: - VoiceSelectionView +// Sheet for selecting TTS voice. Loads voices from the API, plays sample audio, and +// saves the selection back to user settings on confirm. +// VoiceSelectionViewModel is defined in PlayerViews.swift (shared with the full player). + +struct VoiceSelectionView: View { + @EnvironmentObject private var authStore: AuthStore + @Environment(\.dismiss) private var dismiss + + @State private var selectedVoice: String + @State private var vm = VoiceSelectionViewModel() + + init(currentVoice: String) { + _selectedVoice = State(initialValue: currentVoice) + } + + var body: some View { + NavigationStack { + Group { + if vm.isLoading { + loadingState + } else if let error = vm.error { + errorState(error) + } else { + voiceList + } + } + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .navigationTitle("Select Voice") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + vm.stopSample() + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { saveAndDismiss() } + .fontWeight(.semibold) + .foregroundStyle(Color.amber) + .disabled(selectedVoice == authStore.settings.voice) + } + } + .task { await vm.loadVoices() } + .onDisappear { vm.stopSample() } + } + } + + // MARK: - States + + private var loadingState: some View { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.3) + Text("Loading voices…") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func errorState(_ message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundStyle(Color.amber) + .symbolEffect(.pulse) + Text(message) + .font(.subheadline) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal, 32) + Button("Retry") { Task { await vm.loadVoices() } } + .font(.subheadline.bold()) + .foregroundStyle(Color.amber) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Voice list + + private var voiceList: some View { + List { + Section { + ForEach(vm.voices, id: \.self) { voice in + VoiceSelectionRow( + voice: voice, + isSelected: voice == selectedVoice, + isPlaying: vm.playingVoice == voice, + voiceLabel: vm.voiceLabel(voice), + voiceId: vm.voiceId(voice), + onSelect: { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + vm.stopSample() + selectedVoice = voice + }, + onPlaySample: { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + Task { await vm.playSample(voice) } + } + ) + } + } header: { + Text("Available Voices") + .font(.subheadline.bold()) + .foregroundStyle(.secondary) + .textCase(nil) + } footer: { + if selectedVoice != authStore.settings.voice { + Text("New voice will apply to the next audio playback.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .scrollContentBackground(.hidden) + .listStyle(.insetGrouped) + } + + // MARK: - Save + + private func saveAndDismiss() { + vm.stopSample() + Task { + var s = authStore.settings + s.voice = selectedVoice + await authStore.saveSettings(s) + dismiss() + } + } +} + +// MARK: - VoiceSelectionRow + +private struct VoiceSelectionRow: View { + let voice: String + let isSelected: Bool + let isPlaying: Bool + let voiceLabel: String + let voiceId: String + let onSelect: () -> Void + let onPlaySample: () -> Void + + var body: some View { + HStack(spacing: 12) { + // Selection indicator + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 22)) + .foregroundStyle(isSelected ? Color.amber : Color.secondary.opacity(0.4)) + .frame(width: 28) + .contentTransition(.symbolEffect(.replace.downUp)) + .accessibilityHidden(true) + + // Voice name + id + VStack(alignment: .leading, spacing: 3) { + Text(voiceLabel) + .font(.body) + .fontWeight(isSelected ? .semibold : .regular) + Text(voiceId) + .font(.caption) + .fontDesign(.monospaced) + .foregroundStyle(.secondary) + } + + Spacer() + + // Play sample button + Button { + onPlaySample() + } label: { + Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill") + .font(.system(size: 28)) + .foregroundStyle(isPlaying ? Color.red : Color.amber) + .contentTransition(.symbolEffect(.replace.downUp)) + } + .buttonStyle(.plain) + .frame(minWidth: 44, minHeight: 44) + .accessibilityLabel(isPlaying ? "Stop sample for \(voiceLabel)" : "Play sample for \(voiceLabel)") + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + .onTapGesture { onSelect() } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + } +} diff --git a/ios/LibNovelV2/Views/Search/SearchView.swift b/ios/LibNovelV2/Views/Search/SearchView.swift new file mode 100644 index 0000000..f25689a --- /dev/null +++ b/ios/LibNovelV2/Views/Search/SearchView.swift @@ -0,0 +1,255 @@ +import SwiftUI + +// MARK: - SearchView +// Full-screen search tab. +// Idle: recent searches list (or prompt if empty). +// Active: debounced live results in a 2-col grid with local/remote count header. + +struct SearchView: View { + @State private var vm = SearchViewModel() + @EnvironmentObject private var networkMonitor: NetworkMonitor + + private let columns = [ + GridItem(.flexible(), spacing: 14), + GridItem(.flexible(), spacing: 14), + ] + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + OfflineBanner() + + Group { + if vm.isLoading { + loadingState + } else if !vm.query.isEmpty && vm.results.isEmpty { + emptyResultsState + } else if !vm.results.isEmpty { + resultsGrid + } else { + idleState + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .appNavigationDestination() + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .navigationTitle("Search") + .navigationBarTitleDisplayMode(.large) + .searchable( + text: $vm.query, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search novels, authors…" + ) + .onChange(of: vm.query) { _, newValue in + guard networkMonitor.isConnected else { return } + vm.onQueryChange(newValue) + } + .onSubmit(of: .search) { + guard networkMonitor.isConnected else { return } + UIImpactFeedbackGenerator(style: .light).impactOccurred() + vm.submitSearch() + } + .errorAlert($vm.error) + } + } + + // MARK: - Idle state + + @ViewBuilder + private var idleState: some View { + if vm.recentSearches.isEmpty { + emptyIdleState + } else { + recentSearchesList + } + } + + private var emptyIdleState: some View { + VStack(spacing: 16) { + Spacer() + Image(systemName: "magnifyingglass") + .font(.system(size: 60)) + .foregroundStyle(.tertiary) + Text("Search for novels") + .font(.title3.bold()) + .foregroundStyle(.primary) + Text("Find books by title, author, or genre") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + Spacer() + } + } + + private var recentSearchesList: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Recent Searches") + .font(.subheadline.bold()) + .foregroundStyle(.secondary) + Spacer() + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + vm.clearRecent() + } label: { + Text("Clear") + .font(.subheadline) + .foregroundStyle(Color.amber) + } + .frame(minWidth: 44, minHeight: 44) + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 4) + + ForEach(vm.recentSearches, id: \.self) { term in + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + vm.selectRecent(term) + } label: { + HStack(spacing: 12) { + Image(systemName: "clock") + .font(.subheadline) + .foregroundStyle(.tertiary) + .frame(width: 24) + Text(term) + .font(.body) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer() + Image(systemName: "arrow.up.left") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .frame(minHeight: 44) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + + Divider() + .padding(.leading, 52) + } + } + + Color.clear.frame(height: 120) + } + } + + // MARK: - Loading state + + private var loadingState: some View { + VStack { + Spacer() + ProgressView() + .tint(Color.amber) + .scaleEffect(1.4) + Spacer() + } + } + + // MARK: - Empty results state + + private var emptyResultsState: some View { + VStack { + Spacer() + EmptyStateView( + icon: "magnifyingglass", + title: "No results", + message: "Nothing matched \"\(vm.query)\". Try a different term." + ) + Spacer() + } + } + + // MARK: - Results grid + + private var resultsGrid: some View { + ScrollView { + // Count header + HStack(spacing: 6) { + Text("\(vm.results.count) results") + .font(.subheadline.bold()) + .foregroundStyle(.primary) + + if vm.localCount > 0 || vm.remoteCount > 0 { + Text("·") + .foregroundStyle(.tertiary) + if vm.localCount > 0 { + Text("\(vm.localCount) in library") + .font(.caption) + .foregroundStyle(Color.amber) + } + if vm.localCount > 0 && vm.remoteCount > 0 { + Text("+") + .font(.caption) + .foregroundStyle(.tertiary) + } + if vm.remoteCount > 0 { + Text("\(vm.remoteCount) online") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 4) + + LazyVGrid(columns: columns, spacing: 14) { + ForEach(vm.results) { novel in + NavigationLink(value: NavDestination.book(novel.slug)) { + SearchNovelCard(novel: novel) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.top, 4) + + Color.clear.frame(height: 120) + } + } +} + +// MARK: - SearchNovelCard + +private struct SearchNovelCard: View { + let novel: BrowseNovel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + AsyncCoverImage(url: novel.cover) + .frame(maxWidth: .infinity) + .aspectRatio(2/3, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .bookCoverZoomSource(slug: novel.slug) + + VStack(alignment: .leading, spacing: 3) { + Text(novel.title) + .font(.subheadline.bold()) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + if !novel.author.isEmpty { + Text(novel.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2) + } +} diff --git a/ios/LibNovelV2/features.md b/ios/LibNovelV2/features.md new file mode 100644 index 0000000..726b266 --- /dev/null +++ b/ios/LibNovelV2/features.md @@ -0,0 +1,57 @@ +# LibNovel v2 iOS — Feature Tracker + +Design reference: `ui/src/routes/` (SvelteKit web UI) +All new code lives in `ios/LibNovelV2/`. + +--- + +## Status legend +- ✅ Done +- 🔨 In progress +- ⏳ Not started + +--- + +## Features + +| # | Feature | Files | Status | +|---|---------|-------|--------| +| 1 | Directory scaffold | `ios/LibNovelV2/` tree | ✅ | +| 2 | Models | `Models/Models.swift` | ✅ | +| 3 | Networking | `Networking/APIClient.swift` | ✅ | +| 4 | Services | `AuthStore`, `AudioPlayerService`, `AudioDownloadService`, `NetworkMonitor`, `BookVoicePreferences` | ✅ | +| 4b | App entry + RootTabView + stub views | `App/LibNovelV2App.swift`, `App/ContentView.swift`, `App/RootTabView.swift`, `Extensions/NavDestination.swift` | ✅ | +| 5 | Auth / Login | `Views/Auth/AuthView.swift` | ✅ | +| 6 | Home screen | `Views/Home/HomeView.swift`, `ViewModels/HomeViewModel.swift`, `Views/Common/CommonViews.swift` | ✅ | +| 7 | Library screen | `Views/Library/LibraryView.swift`, `ViewModels/LibraryViewModel.swift` | ✅ | +| 8 | Browse / Discover | `Views/Browse/BrowseView.swift`, `Views/Browse/BrowseCategoryView.swift`, `ViewModels/BrowseViewModel.swift` | ✅ | +| 9 | Search | `Views/Search/SearchView.swift`, `ViewModels/SearchViewModel.swift` | ✅ | +| 10 | Book Detail | `Views/BookDetail/BookDetailView.swift`, `ViewModels/BookDetailViewModel.swift` | ✅ | +| 11 | Chapter Reader | `Views/ChapterReader/ChapterReaderView.swift`, `ViewModels/ChapterReaderViewModel.swift` | ✅ | +| 12 | Audio mini-player + full player | `Views/Player/PlayerViews.swift` | ✅ | +| 13 | Downloads screen | `Views/Downloads/DownloadsView.swift` | ✅ | +| 14 | Profile / Account | `Views/Profile/ProfileView.swift`, `Views/Profile/VoiceSelectionView.swift` | ✅ | + +--- + +## Design system recap + +| Token | Value | +|-------|-------| +| Main bg | `zinc-900` `#18181b` | +| Card bg | `zinc-800` `#27272a` | +| Border | `zinc-700` `#3f3f46` | +| Primary text | `zinc-100` `#f4f4f5` | +| Secondary text | `zinc-400` `#a1a1aa` | +| Accent / CTA | `amber-400` `#f59e0b` | + +## Key patterns (quick ref) + +- **Cover images**: always proxy via `/api/cover/{domain}/{slug}` +- **Download keys**: `slug::chapterN::voice` (`::` separator — slugs contain `-`) +- **Voice fallback**: book override → global default → `"af_bella"` +- **Offline**: `NetworkMonitor` env object + `OfflineBanner` at top of every networked view +- **Observable**: new types use `@Observable`; existing services use `ObservableObject` +- **Navigation**: `NavigationStack` + `NavDestination` enum + `.appNavigationDestination()` +- **Haptics**: `.light` for selection, `.medium` for primary actions +- **Animations**: `.spring(response:dampingFraction:)` for all interactive transitions diff --git a/ios/LibNovelV2/project.yml b/ios/LibNovelV2/project.yml new file mode 100644 index 0000000..80fd17c --- /dev/null +++ b/ios/LibNovelV2/project.yml @@ -0,0 +1,70 @@ +name: LibNovelV2 +options: + bundleIdPrefix: com.kalekber + deploymentTarget: + iOS: "17.0" + xcodeVersion: "16.0" + generateEmptyDirectories: true + indentWidth: 4 + tabWidth: 4 + usesTabs: false + +settings: + base: + SWIFT_VERSION: "5.10" + ENABLE_PREVIEWS: YES + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + LIBNOVEL_BASE_URL: "https://v2.libnovel.kalekber.cc" + configs: + Debug: + SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG + Release: + SWIFT_ACTIVE_COMPILATION_CONDITIONS: "" + +targets: + LibNovelV2: + type: application + platform: iOS + deploymentTarget: "17.0" + sources: + - path: . + excludes: + - "**/.DS_Store" + - "Resources/Info.plist" + - "Resources/Assets.xcassets" + - "features.md" + - "project.yml" + resources: + - path: Resources/Assets.xcassets + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovelV2 + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad + GENERATE_INFOPLIST_FILE: NO + INFOPLIST_FILE: Resources/Info.plist + configs: + Release: + CODE_SIGN_STYLE: Manual + DEVELOPMENT_TEAM: GHZXC6FVMU + CODE_SIGN_IDENTITY: "Apple Distribution" + PROVISIONING_PROFILE: "af592c3a-f60b-4ac1-a14f-30b8a206017f" + +schemes: + LibNovelV2: + build: + targets: + LibNovelV2: all + run: + config: Debug + environmentVariables: + LIBNOVEL_BASE_URL: + value: "https://v2.libnovel.kalekber.cc" + isEnabled: true + profile: + config: Release + analyze: + config: Debug + archive: + config: Release diff --git a/justfile b/justfile new file mode 100644 index 0000000..9ffca1c --- /dev/null +++ b/justfile @@ -0,0 +1,234 @@ +# justfile — libnovel-v2 task runner +# Install just: https://just.systems + +scraper_dir := "scraper" +ui_dir := "ui" +ios_dir := "ios/LibNovel" +ios_scheme := "LibNovel" +ios_sim := "platform=iOS Simulator,name=iPhone 17" +ios_spm := ".spm-cache" +runner_temp := env_var_or_default("RUNNER_TEMP", "/tmp") + +# ─── Build ──────────────────────────────────────────────────────────────────── + +# Build the scraper binary +build: + cd {{scraper_dir}} && go build -o bin/scraper ./cmd/scraper + +# Build and verify all Go packages compile cleanly +build-all: + cd {{scraper_dir}} && go build ./... + +# ─── Tests ──────────────────────────────────────────────────────────────────── + +# Run unit tests only (no integration services required) +test: + cd {{scraper_dir}} && go test -race -count=1 -timeout=60s ./... + +# Run integration tests (requires MinIO, PocketBase, optional Browserless) +# Override env vars as needed, e.g.: +# just test-integration MINIO_ENDPOINT=localhost:9000 +test-integration: + cd {{scraper_dir}} && go test -v -tags integration -timeout 600s ./... + +# Run unit + integration tests +test-all: test test-integration + +# Run a specific package's integration tests, e.g.: +# just test-pkg internal/storage +test-pkg pkg: + cd {{scraper_dir}} && go test -v -tags integration -timeout 600s ./{{pkg}}/... + +# Run end-to-end tests against live services. +# All services must be running first (docker compose up -d or just e2e-up). +# Override env vars as needed, e.g.: +# just test-e2e SCRAPER_URL=http://localhost:8080 KOKORO_VOICE=af_bella +test-e2e \ + browserless_url="http://localhost:3030" \ + minio_endpoint="localhost:9000" \ + pocketbase_url="http://localhost:8090" \ + scraper_url="http://localhost:8080": + cd {{scraper_dir}} && \ + BROWSERLESS_URL={{browserless_url}} \ + MINIO_ENDPOINT={{minio_endpoint}} \ + POCKETBASE_URL={{pocketbase_url}} \ + SCRAPER_URL={{scraper_url}} \ + go test -v -tags integration -timeout 900s ./internal/e2e/... + +# Start all services required for e2e tests, then run them +e2e: up test-e2e + +# ─── Code quality ───────────────────────────────────────────────────────────── + +# Run go vet on all packages (including integration build tag) +lint: + cd {{scraper_dir}} && go vet ./... + cd {{scraper_dir}} && go vet -tags integration ./... + +# ─── UI ─────────────────────────────────────────────────────────────────────── + +# Type-check the SvelteKit UI +ui-check: + cd {{ui_dir}} && npx svelte-check + +# Start the SvelteKit dev server +ui-dev: + cd {{ui_dir}} && npm run dev + +# Install UI dependencies +ui-install: + cd {{ui_dir}} && npm install + +# Build the UI for production +ui-build: + cd {{ui_dir}} && npm run build + +# ─── iOS ────────────────────────────────────────────────────────────────────── + +# Regenerate LibNovel.xcodeproj from project.yml (run after structural changes) +ios-gen: + cd {{ios_dir}} && xcodegen generate --spec project.yml --project . + +# Resolve SPM package dependencies (cached to {{ios_spm}}) +ios-resolve: + cd {{ios_dir}} && xcodebuild \ + -project {{ios_scheme}}.xcodeproj \ + -scheme {{ios_scheme}} \ + -resolvePackageDependencies \ + -clonedSourcePackagesDirPath {{ios_spm}} + +# Build the iOS app for the simulator (no signing required) +# Runs ios-gen first to ensure the project is up to date. +ios-build: ios-gen ios-resolve + cd {{ios_dir}} && set -o pipefail && xcodebuild \ + -project {{ios_scheme}}.xcodeproj \ + -scheme {{ios_scheme}} \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -clonedSourcePackagesDirPath {{ios_spm}} \ + CODE_SIGNING_ALLOWED=NO \ + | xcpretty || xcodebuild \ + -project {{ios_scheme}}.xcodeproj \ + -scheme {{ios_scheme}} \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -clonedSourcePackagesDirPath {{ios_spm}} \ + CODE_SIGNING_ALLOWED=NO + +# Run unit tests on the simulator +# Runs ios-gen first to ensure the project is up to date. +ios-test: ios-gen ios-resolve + cd {{ios_dir}} && set -o pipefail && xcodebuild test \ + -project {{ios_scheme}}.xcodeproj \ + -scheme {{ios_scheme}} \ + -configuration Debug \ + -destination '{{ios_sim}}' \ + -clonedSourcePackagesDirPath {{ios_spm}} \ + CODE_SIGNING_ALLOWED=NO \ + | xcpretty --report junit --output test-results.xml || true + +# Archive a signed Release build (requires valid signing identity in keychain). +# Output: {{runner_temp}}/LibNovel.xcarchive +# Typically called from CI after importing certificate + provisioning profile. +# Usage: just ios-archive +ios-archive team_id profile_uuid: ios-gen ios-resolve + cd {{ios_dir}} && xcodebuild archive \ + -project {{ios_scheme}}.xcodeproj \ + -scheme {{ios_scheme}} \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -clonedSourcePackagesDirPath {{ios_spm}} \ + -archivePath {{runner_temp}}/LibNovel.xcarchive \ + CODE_SIGN_IDENTITY="Apple Distribution" \ + "PROVISIONING_PROFILE[sdk=iphoneos*]={{profile_uuid}}" \ + DEVELOPMENT_TEAM="{{team_id}}" + +# Export an IPA from the archive produced by ios-archive. +# Requires ios/LibNovel/ExportOptions.plist. +# Output: {{runner_temp}}/ipa/LibNovel.ipa +ios-export: + cd {{ios_dir}} && xcodebuild -exportArchive \ + -archivePath {{runner_temp}}/LibNovel.xcarchive \ + -exportPath {{runner_temp}}/ipa \ + -exportOptionsPlist ExportOptions.plist + +# Set the build number (CFBundleVersion) in project.yml before archiving. +# Usage: just ios-set-build-number 42 +ios-set-build-number number: + cd {{ios_dir}} && sed -i '' \ + 's/CURRENT_PROJECT_VERSION: .*/CURRENT_PROJECT_VERSION: {{number}}/' \ + project.yml + +# Upload the exported IPA to TestFlight via App Store Connect API. +# Requires env vars: ASC_KEY_ID, ASC_ISSUER_ID, ASC_PRIVATE_KEY_PATH +# The private key (.p8 file) must be present at ASC_PRIVATE_KEY_PATH. +ios-upload: + xcrun altool --upload-app \ + --type ios \ + --file {{runner_temp}}/ipa/LibNovel.ipa \ + --apiKey "$ASC_KEY_ID" \ + --apiIssuer "$ASC_ISSUER_ID" + +# ─── Docker Compose ─────────────────────────────────────────────────────────── + +# Start all services (browserless, kokoro, scraper, minio, pocketbase) +up: + docker compose up -d + +# Stop all services +down: + docker compose down + +# Tail logs for all services +logs: + docker compose logs -f + +# Tail logs for a specific service, e.g.: just logs-service scraper +logs-service service: + docker compose logs -f {{service}} + +# Rebuild and restart a specific service +restart service: + docker compose up -d --build {{service}} + +# ─── Local dev: individual services ────────────────────────────────────────── + +# Start only PocketBase (for local storage testing) +pb-up: + docker compose up -d pocketbase + +# Start only MinIO (for local storage testing) +minio-up: + docker compose up -d minio + +# Start only Browserless (for local scraping tests) +browserless-up: + docker compose up -d browserless + +# Start storage backends only (MinIO + PocketBase) +storage-up: + docker compose up -d minio pocketbase + +# ─── Convenience ───────────────────────────────────────────────────────────── + +# Show status of all docker compose services +status: + docker compose ps + +# Remove all stopped containers and unused images +prune: + docker compose down --remove-orphans + docker image prune -f + +# One-shot scrape of the full catalogue (requires services to be running) +scrape-run: build + cd {{scraper_dir}} && ./bin/scraper run + +# One-shot scrape of a single book URL, e.g.: +# just scrape-book https://novelfire.net/book/my-novel +scrape-book url: build + cd {{scraper_dir}} && ./bin/scraper run --url {{url}} + +# Start the HTTP server +serve: build + cd {{scraper_dir}} && ./bin/scraper serve diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..e15c722 --- /dev/null +++ b/opencode.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "gh_grep": { + "type": "remote", + "url": "https://mcp.grep.app", + "enabled": true + } + }, + "instructions": [ + "ios/AGENTS.md" + ] +} diff --git a/scraper/.dockerignore b/scraper/.dockerignore new file mode 100644 index 0000000..f7714ca --- /dev/null +++ b/scraper/.dockerignore @@ -0,0 +1,4 @@ +bin/ +static/ +*.md +.git diff --git a/scraper/Dockerfile b/scraper/Dockerfile index a16a59d..c596bd4 100644 --- a/scraper/Dockerfile +++ b/scraper/Dockerfile @@ -9,13 +9,20 @@ RUN go mod download COPY . . +# Build-time version info — injected by docker-compose or CI via --build-arg. +ARG VERSION=dev +ARG COMMIT=unknown + RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ - go build -ldflags="-s -w" -o /scraper ./cmd/scraper + go build \ + -ldflags="-s -w -X main.Version=${VERSION} -X main.Commit=${COMMIT}" \ + -o /scraper ./cmd/scraper # ── Runtime stage ────────────────────────────────────────────────────────────── -FROM alpine:3.20 +FROM alpine:3.21 -# ca-certificates is required for HTTPS requests to novelfire.net. +# ca-certificates: HTTPS to novelfire.net +# tzdata: timezone data RUN apk add --no-cache ca-certificates tzdata WORKDIR /app @@ -31,8 +38,6 @@ RUN chown -R scraper:scraper /app USER scraper # ── Configuration ───────────────────────────────────────────────────────────── -ENV BROWSERLESS_URL=http://browserless:3030 -ENV BROWSERLESS_STRATEGY=content ENV SCRAPER_WORKERS=0 ENV SCRAPER_STATIC_ROOT=/app/static/books ENV SCRAPER_HTTP_ADDR=:8080 diff --git a/scraper/cmd/scraper/main.go b/scraper/cmd/scraper/main.go index f3c6962..d4d8e36 100644 --- a/scraper/cmd/scraper/main.go +++ b/scraper/cmd/scraper/main.go @@ -10,16 +10,24 @@ // // Environment variables: // -// BROWSERLESS_URL Browserless base URL (default: http://localhost:3030) -// BROWSERLESS_TOKEN Browserless API token (default: "") -// BROWSERLESS_STRATEGY content | scrape | cdp (default: content) -// BROWSERLESS_MAX_CONCURRENT Max simultaneous browser sessions (default: 5) -// SCRAPER_WORKERS Chapter goroutine count (default: NumCPU) -// SCRAPER_STATIC_ROOT Output directory (default: ./static/books) -// SCRAPER_HTTP_ADDR HTTP listen address (default: :8080) -// KOKORO_URL Kokoro-FastAPI base URL (default: "") -// KOKORO_VOICE Default TTS voice (default: af_bella) -// LOG_LEVEL debug | info | warn | error (default: info) +// SCRAPER_WORKERS Chapter goroutine count (default: NumCPU) +// SCRAPER_HTTP_ADDR HTTP listen address (default: :8080) +// SCRAPER_PROXY Outbound proxy for all scraper requests, e.g. +// http://user:pass@proxy-host:3128 — use a +// residential proxy to bypass datacenter IP blocks. +// Falls back to HTTP_PROXY / HTTPS_PROXY if unset. +// KOKORO_URL Kokoro-FastAPI base URL (default: "") +// KOKORO_VOICE Default TTS voice (default: af_bella) +// POCKETBASE_URL PocketBase API base URL (default: http://localhost:8090) +// POCKETBASE_ADMIN_EMAIL PocketBase admin email (default: admin@libnovel.local) +// POCKETBASE_ADMIN_PASSWORD PocketBase admin password (default: changeme123) +// MINIO_ENDPOINT MinIO endpoint host:port (default: localhost:9000) +// MINIO_ACCESS_KEY MinIO access key (default: admin) +// MINIO_SECRET_KEY MinIO secret key (default: changeme123) +// MINIO_USE_SSL Use TLS for MinIO (default: false) +// MINIO_BUCKET_CHAPTERS Chapter objects bucket (default: libnovel-chapters) +// MINIO_BUCKET_AUDIO Audio objects bucket (default: libnovel-audio) +// LOG_LEVEL debug | info | warn | error (default: info) package main import ( @@ -27,6 +35,7 @@ import ( "fmt" "log/slog" "os" + "os/exec" "os/signal" "runtime" "strconv" @@ -37,8 +46,16 @@ import ( "github.com/libnovel/scraper/internal/browser" "github.com/libnovel/scraper/internal/novelfire" "github.com/libnovel/scraper/internal/orchestrator" + "github.com/libnovel/scraper/internal/scraper/htmlutil" "github.com/libnovel/scraper/internal/server" - "github.com/libnovel/scraper/internal/writer" + "github.com/libnovel/scraper/internal/storage" +) + +// Build-time version info — injected via -ldflags during docker build. +// Falls back to "dev" / "unknown" when built without -ldflags (local dev). +var ( + Version = "dev" + Commit = "unknown" ) func main() { @@ -67,30 +84,45 @@ func run(log *slog.Logger) error { cmd := strings.ToLower(args[0]) - browserCfg := browser.Config{ - BaseURL: envOr("BROWSERLESS_URL", "http://localhost:3030"), - Token: envOr("BROWSERLESS_TOKEN", ""), - } - browserCfg.MaxConcurrent = 5 - if s := os.Getenv("BROWSERLESS_MAX_CONCURRENT"); s != "" { + // All scraping uses direct HTTP — novelfire.net pages are server-rendered + // and do not require a headless browser. A direct HTTP client is faster, + // more reliable, and has no Browserless dependency. + directCfg := browser.Config{MaxConcurrent: 5} + if s := os.Getenv("SCRAPER_TIMEOUT"); s != "" { if n, err := strconv.Atoi(s); err == nil && n > 0 { - browserCfg.MaxConcurrent = n + directCfg.Timeout = time.Duration(n) * time.Second } } - if s := os.Getenv("BROWSERLESS_TIMEOUT"); s != "" { - if n, err := strconv.Atoi(s); err == nil && n > 0 { - browserCfg.Timeout = time.Duration(n) * time.Second - } + directClient := browser.NewDirectHTTPClient(directCfg) + + // ── Storage backends ──────────────────────────────────────────────────── + minioCfg := storage.MinioConfig{ + Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"), + PublicEndpoint: envOr("MINIO_PUBLIC_ENDPOINT", ""), + AccessKey: envOr("MINIO_ACCESS_KEY", "admin"), + SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"), + UseSSL: strings.ToLower(os.Getenv("MINIO_USE_SSL")) == "true", + PublicUseSSL: strings.ToLower(os.Getenv("MINIO_PUBLIC_USE_SSL")) != "false", + BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"), + BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"), + BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "libnovel-browse"), + BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "libnovel-avatars"), + } + pbCfg := storage.PocketBaseConfig{ + BaseURL: envOr("POCKETBASE_URL", "http://localhost:8090"), + AdminEmail: envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local"), + AdminPassword: envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123"), } - strategy := browser.Strategy(strings.ToLower(envOr("BROWSERLESS_STRATEGY", string(browser.StrategyDirect)))) - urlStrategy := browser.Strategy(strings.ToLower(envOr("BROWSERLESS_URL_STRATEGY", string(browser.StrategyContent)))) - bc := newBrowserClient(strategy, browserCfg) - urlClient := newBrowserClient(urlStrategy, browserCfg) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() - staticRoot := envOr("SCRAPER_STATIC_ROOT", "./static/books") - w := writer.New(staticRoot) - nf := novelfire.New(bc, log, urlClient, w) + store, err := storage.NewHybridStore(ctx, pbCfg, minioCfg, log) + if err != nil { + return fmt.Errorf("storage init failed: %w", err) + } + + nf := novelfire.New(directClient, log, directClient, directClient, store) workers := 0 if s := os.Getenv("SCRAPER_WORKERS"); s != "" { @@ -104,13 +136,9 @@ func run(log *slog.Logger) error { } oCfg := orchestrator.Config{ - Workers: workers, - StaticRoot: staticRoot, + Workers: workers, } - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - switch cmd { case "run": // Optional --url flag. @@ -118,13 +146,13 @@ func run(log *slog.Logger) error { oCfg.SingleBookURL = args[2] } log.Info("starting one-shot scrape", - "strategy", strategy, + "strategy", "direct", "workers", workers, - "max_concurrent", browserCfg.MaxConcurrent, - "static_root", oCfg.StaticRoot, "single_book", oCfg.SingleBookURL, + "pocketbase_url", pbCfg.BaseURL, + "pocketbase_email", pbCfg.AdminEmail, ) - o := orchestrator.New(oCfg, nf, log) + o := orchestrator.New(oCfg, nf, log, store) return o.Run(ctx) case "refresh": @@ -133,13 +161,12 @@ func run(log *slog.Logger) error { return fmt.Errorf("refresh command requires a book slug argument") } slug := args[1] - w := writer.New(oCfg.StaticRoot) - meta, ok, err := w.ReadMetadata(slug) + meta, ok, err := store.ReadMetadata(ctx, slug) if err != nil { return fmt.Errorf("failed to read metadata for %s: %w", slug, err) } if !ok { - return fmt.Errorf("book %q not found in %s", slug, oCfg.StaticRoot) + return fmt.Errorf("book %q not found in store", slug) } if meta.SourceURL == "" { return fmt.Errorf("book %q has no source_url in metadata", slug) @@ -148,41 +175,301 @@ func run(log *slog.Logger) error { log.Info("refreshing book from source_url", "slug", slug, "source_url", meta.SourceURL, + "pocketbase_url", pbCfg.BaseURL, + "pocketbase_email", pbCfg.AdminEmail, ) - o := orchestrator.New(oCfg, nf, log) + o := orchestrator.New(oCfg, nf, log, store) return o.Run(ctx) case "serve": addr := envOr("SCRAPER_HTTP_ADDR", ":8080") - kokoroURL := envOr("KOKORO_URL", "") + kokoroURL := envOr("KOKORO_URL", "https://kokoro.kalekber.cc") kokoroVoice := envOr("KOKORO_VOICE", "af_bella") log.Info("starting HTTP server", "addr", addr, - "strategy", strategy, + "strategy", "direct", "workers", workers, - "max_concurrent", browserCfg.MaxConcurrent, "kokoro_url", kokoroURL, "kokoro_voice", kokoroVoice, + "pocketbase_url", pbCfg.BaseURL, + "pocketbase_email", pbCfg.AdminEmail, ) - srv := server.New(addr, oCfg, nf, log, kokoroURL, kokoroVoice) + srv := server.New(addr, oCfg, nf, log, store, kokoroURL, kokoroVoice, Version, Commit) return srv.ListenAndServe(ctx) + case "save-browse": + return runSaveBrowse(ctx, args[1:], store, log) + default: - return fmt.Errorf("unknown command %q; use 'run' or 'serve'", cmd) + return fmt.Errorf("unknown command %q; use 'run', 'refresh', 'serve', or 'save-browse'", cmd) } } -func newBrowserClient(strategy browser.Strategy, cfg browser.Config) browser.BrowserClient { - switch strategy { - case browser.StrategyScrape: - return browser.NewScrapeClient(cfg) - case browser.StrategyCDP: - return browser.NewCDPClient(cfg) - case browser.StrategyDirect: - return browser.NewDirectHTTPClient(cfg) - default: - return browser.NewContentClient(cfg) +// runSaveBrowse implements the `save-browse` subcommand. +// It iterates over browse pages on novelfire.net, captures each using +// SingleFile CLI (connected to the existing Browserless instance), and +// stores the resulting self-contained HTML in the MinIO browse bucket. +// After storing each page it parses the HTML, upserts ranking records in +// PocketBase, and fires background goroutines to download cover images. +// +// Flags (all optional): +// +// --genre genre slug (default: all) +// --sort sort order (default: popular) +// --status status (default: all) +// --type novel type (default: all-novel) +// --max-pages max pages (default: 5) +func runSaveBrowse(ctx context.Context, args []string, store storage.Store, log *slog.Logger) error { + // Parse flags manually to avoid importing flag package. + genre := "all" + sortBy := "popular" + status := "all" + novelType := "all-novel" + maxPages := 5 + + for i := 0; i < len(args); i++ { + switch args[i] { + case "--genre": + if i+1 < len(args) { + genre = args[i+1] + i++ + } + case "--sort": + if i+1 < len(args) { + sortBy = args[i+1] + i++ + } + case "--status": + if i+1 < len(args) { + status = args[i+1] + i++ + } + case "--type": + if i+1 < len(args) { + novelType = args[i+1] + i++ + } + case "--max-pages": + if i+1 < len(args) { + if n, err := strconv.Atoi(args[i+1]); err == nil && n > 0 { + maxPages = n + } + i++ + } + } } + + singleFilePath := envOr("SINGLEFILE_PATH", "single-file") + browserlessURL := envOr("BROWSERLESS_URL", "http://localhost:3030") + // SingleFile expects a WebSocket CDP endpoint. + // Browserless exposes /chromium at the WS root. + wsEndpoint := strings.Replace(browserlessURL, "http://", "ws://", 1) + wsEndpoint = strings.Replace(wsEndpoint, "https://", "wss://", 1) + + log.Info("save-browse: starting", + "genre", genre, "sort", sortBy, "status", status, + "type", novelType, "max_pages", maxPages, + "singlefile", singleFilePath, + "browserless_ws", wsEndpoint, + ) + + tmpDir, err := os.MkdirTemp("", "libnovel-browse-*") + if err != nil { + return fmt.Errorf("save-browse: create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + const novelFireBase = "https://novelfire.net" + const novelFireDomain = "novelfire.net" + + for page := 1; page <= maxPages; page++ { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + pageURL := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=%d", + novelFireBase, genre, sortBy, status, novelType, page) + + // Use the new domain-based key layout: {domain}/html/page-{n}.html + key := store.BrowseHTMLKey(novelFireDomain, page) + + outFile := fmt.Sprintf("%s/page-%d.html", tmpDir, page) + + log.Info("save-browse: capturing page", "page", page, "url", pageURL) + + //nolint:gosec // singleFilePath and pageURL are config/URL values, not user input. + cmd := exec.CommandContext(ctx, singleFilePath, + pageURL, + "--browser-server="+wsEndpoint, + "--output="+outFile, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if runErr := cmd.Run(); runErr != nil { + log.Warn("save-browse: SingleFile failed, skipping page", + "page", page, "err", runErr) + continue + } + + htmlBytes, readErr := os.ReadFile(outFile) + if readErr != nil { + log.Warn("save-browse: failed to read output file", + "page", page, "file", outFile, "err", readErr) + continue + } + + if putErr := store.SaveBrowsePage(ctx, key, string(htmlBytes)); putErr != nil { + log.Warn("save-browse: failed to store snapshot in MinIO", + "page", page, "key", key, "err", putErr) + continue + } + + log.Info("save-browse: snapshot stored", "page", page, "key", key, + "bytes", len(htmlBytes)) + + // Parse the stored HTML and populate the ranking collection. + novels := parseSaveBrowseListings(htmlBytes, novelFireBase) + for i, novel := range novels { + rank := i + 1 + coverKey := store.BrowseCoverKey(novelFireDomain, novel.slug) + + item := storage.RankingItem{ + Rank: rank, + Slug: novel.slug, + Title: novel.title, + Cover: coverKey, + SourceURL: novel.url, + } + if werr := store.WriteRankingItem(ctx, item); werr != nil { + log.Warn("save-browse: WriteRankingItem failed", + "slug", novel.slug, "err", werr) + } + + // Download cover image in the background (best-effort). + if novel.coverURL != "" { + go storage.DownloadAndStoreCover(store, log, coverKey, novel.coverURL) + } + } + if len(novels) > 0 { + log.Info("save-browse: ranking populated", "page", page, "count", len(novels)) + } + } + + log.Info("save-browse: done") + return nil +} + +// novelListingCLI is a minimal novel listing used within the CLI command. +type novelListingCLI struct { + slug string + title string + url string + coverURL string +} + +// parseSaveBrowseListings extracts novel listings from raw HTML bytes. +// It reuses the same parsing logic as the server's parseBrowsePage but +// operates on []byte to avoid importing the server package. +func parseSaveBrowseListings(htmlBytes []byte, novelFireBase string) []novelListingCLI { + type listing = novelListingCLI + + // Minimal tokeniser-based walk to find
  • blocks. + // We use the golang.org/x/net/html parser via a local import. + // Because main.go already imports golang.org/x/net/html indirectly through + // the server package build, we do a simple line-scan here instead to keep + // the dependency surface small. + // + // Strategy: scan for href="/book/{slug}", img data-src/src, h4.novel-title text. + var novels []listing + + lines := strings.Split(string(htmlBytes), "\n") + var cur listing + inNovelItem := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Detect start of a novel-item list element. + if strings.Contains(trimmed, `class="novel-item"`) || strings.Contains(trimmed, "novel-item") && strings.HasPrefix(trimmed, "" && cur.slug != "" { + novels = append(novels, cur) + inNovelItem = false + cur = listing{} + continue + } + + // Extract slug from href="/book/{slug}". + if cur.slug == "" { + if idx := strings.Index(trimmed, `href="/book/`); idx >= 0 { + rest := trimmed[idx+len(`href="/book/`):] + if end := strings.IndexAny(rest, `"/ `); end > 0 { + cur.slug = rest[:end] + cur.url = novelFireBase + "/book/" + cur.slug + } else if end := strings.Index(rest, `"`); end > 0 { + cur.slug = strings.TrimSuffix(rest[:end], "/") + cur.url = novelFireBase + "/book/" + cur.slug + } + } + } + + // Extract cover URL from data-src or src on img tags. + if cur.coverURL == "" && strings.Contains(trimmed, "Title Here + if start := strings.Index(trimmed, ">"); start >= 0 { + rest := trimmed[start+1:] + if end := strings.Index(rest, "<"); end > 0 { + title := strings.TrimSpace(rest[:end]) + if title != "" { + cur.title = title + } + } + } + } + } + + // Flush any open item that wasn't closed by
  • (e.g. last item in file). + if inNovelItem && cur.slug != "" { + novels = append(novels, cur) + } + + return novels +} + +// extractAttr extracts an HTML attribute value from a raw tag string. +// e.g. extractAttr(``, "data-src") → "foo.jpg" +func extractAttr(tag, attr string) string { + needle := attr + `="` + idx := strings.Index(tag, needle) + if idx < 0 { + return "" + } + rest := tag[idx+len(needle):] + end := strings.Index(rest, `"`) + if end < 0 { + return "" + } + return rest[:end] } func envOr(key, fallback string) string { @@ -199,19 +486,32 @@ Commands: run [--url ] One-shot: scrape full catalogue, or a single book refresh Re-scrape a book from its saved source_url serve Start HTTP server (POST /scrape, POST /scrape/book) + save-browse Capture browse pages via SingleFile → MinIO + --genre genre filter (default: all) + --sort sort order (default: popular) + --status status filter (default: all) + --type novel type (default: all-novel) + --max-pages pages to capture (default: 5) Environment variables: - BROWSERLESS_URL Browserless base URL (default: http://localhost:3030) - BROWSERLESS_TOKEN API token (default: "") - BROWSERLESS_STRATEGY content|scrape|cdp|direct (default: direct) - BROWSERLESS_URL_STRATEGY Strategy for URL retrieval (default: content) - BROWSERLESS_MAX_CONCURRENT Max simultaneous sessions (default: 5) - BROWSERLESS_TIMEOUT HTTP request timeout sec (default: 90) SCRAPER_WORKERS Chapter goroutines (default: NumCPU = %d) - SCRAPER_STATIC_ROOT Output directory (default: ./static/books) SCRAPER_HTTP_ADDR HTTP listen address (default: :8080) + SCRAPER_TIMEOUT HTTP request timeout sec (default: 90) + SCRAPER_PROXY Outbound proxy URL (default: "", falls back to HTTP_PROXY/HTTPS_PROXY) KOKORO_URL Kokoro-FastAPI base URL (default: "", TTS disabled) KOKORO_VOICE Default TTS voice (default: af_bella) + POCKETBASE_URL PocketBase base URL (default: http://localhost:8090) + POCKETBASE_ADMIN_EMAIL PocketBase admin email (default: admin@libnovel.local) + POCKETBASE_ADMIN_PASSWORD PocketBase admin password (default: changeme123) + MINIO_ENDPOINT MinIO endpoint host:port (default: localhost:9000) + MINIO_ACCESS_KEY MinIO access key (default: admin) + MINIO_SECRET_KEY MinIO secret key (default: changeme123) + MINIO_USE_SSL MinIO TLS (default: false) + MINIO_BUCKET_CHAPTERS Chapter objects bucket (default: libnovel-chapters) + MINIO_BUCKET_AUDIO Audio objects bucket (default: libnovel-audio) + MINIO_BUCKET_BROWSE Browse snapshots bucket (default: libnovel-browse) + BROWSERLESS_URL Browserless WS endpoint (default: http://localhost:3030) + SINGLEFILE_PATH Path to single-file CLI (default: single-file) LOG_LEVEL debug|info|warn|error (default: info) `, runtime.NumCPU()) } diff --git a/scraper/go.mod b/scraper/go.mod index 10b6635..31fc562 100644 --- a/scraper/go.mod +++ b/scraper/go.mod @@ -3,8 +3,36 @@ module github.com/libnovel/scraper go 1.25.0 require ( - github.com/gorilla/websocket v1.5.3 // indirect - github.com/yuin/goldmark v1.7.16 // indirect - golang.org/x/net v0.51.0 // indirect + github.com/minio/minio-go/v7 v7.0.98 + golang.org/x/net v0.51.0 + honnef.co/go/tools v0.7.0 +) + +require ( + github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +tool honnef.co/go/tools/cmd/staticcheck diff --git a/scraper/go.sum b/scraper/go.sum index 5338185..2f4f489 100644 --- a/scraper/go.sum +++ b/scraper/go.sum @@ -1,9 +1,63 @@ -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= -github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0= +github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ= +golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= +honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= diff --git a/scraper/internal/browser/cdp.go b/scraper/internal/browser/cdp.go deleted file mode 100644 index bdbe0cb..0000000 --- a/scraper/internal/browser/cdp.go +++ /dev/null @@ -1,137 +0,0 @@ -package browser - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strings" - "sync/atomic" - "time" - - "github.com/gorilla/websocket" -) - -// cdpClient implements BrowserClient using the CDP WebSocket endpoint. -type cdpClient struct { - cfg Config - sem chan struct{} -} - -// NewCDPClient returns a BrowserClient that uses CDP WebSocket sessions. -func NewCDPClient(cfg Config) BrowserClient { - if cfg.Timeout == 0 { - cfg.Timeout = 60 * time.Second - } - return &cdpClient{cfg: cfg, sem: makeSem(cfg.MaxConcurrent)} -} - -func (c *cdpClient) Strategy() Strategy { return StrategyCDP } - -func (c *cdpClient) GetContent(_ context.Context, _ ContentRequest) (string, error) { - return "", fmt.Errorf("CDP client does not support /content; use NewContentClient") -} - -func (c *cdpClient) ScrapePage(_ context.Context, _ ScrapeRequest) (ScrapeResponse, error) { - return ScrapeResponse{}, fmt.Errorf("CDP client does not support /scrape; use NewScrapeClient") -} - -// CDPSession opens a WebSocket to the Browserless /devtools/browser endpoint, -// navigates to pageURL, and invokes fn with a live CDPConn. -func (c *cdpClient) CDPSession(ctx context.Context, pageURL string, fn CDPSessionFunc) error { - if err := acquire(ctx, c.sem); err != nil { - return fmt.Errorf("cdp: semaphore: %w", err) - } - defer release(c.sem) - - // Build WebSocket URL: ws://host:port/devtools/browser?token=...&url=... - wsURL := strings.Replace(c.cfg.BaseURL, "http://", "ws://", 1) - wsURL = strings.Replace(wsURL, "https://", "wss://", 1) - wsURL += "/devtools/browser" - sep := "?" - if c.cfg.Token != "" { - wsURL += sep + "token=" + c.cfg.Token - sep = "&" - } - wsURL += sep + "url=" + pageURL - - dialer := websocket.Dialer{ - HandshakeTimeout: 15 * time.Second, - Proxy: http.ProxyFromEnvironment, - } - - conn, _, err := dialer.DialContext(ctx, wsURL, nil) - if err != nil { - return fmt.Errorf("cdp: dial %s: %w", wsURL, err) - } - - cdp := &cdpConn{ws: conn} - defer cdp.Close() - - return fn(ctx, cdp) -} - -// ─── cdpConn ───────────────────────────────────────────────────────────────── - -type cdpConn struct { - ws *websocket.Conn - counter atomic.Int64 -} - -type cdpRequest struct { - ID int64 `json:"id"` - Method string `json:"method"` - Params map[string]any `json:"params,omitempty"` -} - -type cdpResponse struct { - ID int64 `json:"id"` - Result map[string]any `json:"result,omitempty"` - Error *struct { - Code int `json:"code"` - Message string `json:"message"` - } `json:"error,omitempty"` -} - -func (c *cdpConn) Send(ctx context.Context, method string, params map[string]any) (map[string]any, error) { - id := c.counter.Add(1) - - req := cdpRequest{ID: id, Method: method, Params: params} - data, err := json.Marshal(req) - if err != nil { - return nil, fmt.Errorf("cdp send: marshal: %w", err) - } - - if dl, ok := ctx.Deadline(); ok { - _ = c.ws.SetWriteDeadline(dl) - } - if err := c.ws.WriteMessage(websocket.TextMessage, data); err != nil { - return nil, fmt.Errorf("cdp send: write: %w", err) - } - - // Read messages until we find the response matching our id. - for { - if dl, ok := ctx.Deadline(); ok { - _ = c.ws.SetReadDeadline(dl) - } - _, msg, err := c.ws.ReadMessage() - if err != nil { - return nil, fmt.Errorf("cdp send: read: %w", err) - } - var resp cdpResponse - if err := json.Unmarshal(msg, &resp); err != nil { - continue // skip non-JSON frames (events etc.) - } - if resp.ID != id { - continue // event or different command reply - } - if resp.Error != nil { - return nil, fmt.Errorf("cdp error %d: %s", resp.Error.Code, resp.Error.Message) - } - return resp.Result, nil - } -} - -func (c *cdpConn) Close() error { - return c.ws.Close() -} diff --git a/scraper/internal/browser/content_scrape.go b/scraper/internal/browser/common.go similarity index 60% rename from scraper/internal/browser/content_scrape.go rename to scraper/internal/browser/common.go index a0e7a85..a84adf3 100644 --- a/scraper/internal/browser/content_scrape.go +++ b/scraper/internal/browser/common.go @@ -55,6 +55,8 @@ func release(sem chan struct{}) { } } +// ─── /content client ────────────────────────────────────────────────────────── + // contentClient implements BrowserClient using the /content endpoint. type contentClient struct { cfg Config @@ -121,75 +123,5 @@ func (c *contentClient) ScrapePage(_ context.Context, _ ScrapeRequest) (ScrapeRe } func (c *contentClient) CDPSession(_ context.Context, _ string, _ CDPSessionFunc) error { - return fmt.Errorf("content client does not support CDP; use NewCDPClient") -} - -// ─── /scrape client ─────────────────────────────────────────────────────────── - -type scrapeClient struct { - cfg Config - http *http.Client - sem chan struct{} -} - -// NewScrapeClient returns a BrowserClient that uses POST /scrape. -func NewScrapeClient(cfg Config) BrowserClient { - if cfg.Timeout == 0 { - cfg.Timeout = 90 * time.Second - } - return &scrapeClient{ - cfg: cfg, - http: &http.Client{Timeout: cfg.Timeout}, - sem: makeSem(cfg.MaxConcurrent), - } -} - -func (c *scrapeClient) Strategy() Strategy { return StrategyScrape } - -func (c *scrapeClient) GetContent(_ context.Context, _ ContentRequest) (string, error) { - return "", fmt.Errorf("scrape client does not support /content; use NewContentClient") -} - -func (c *scrapeClient) ScrapePage(ctx context.Context, req ScrapeRequest) (ScrapeResponse, error) { - if err := acquire(ctx, c.sem); err != nil { - return ScrapeResponse{}, fmt.Errorf("scrape: semaphore: %w", err) - } - defer release(c.sem) - - body, err := json.Marshal(req) - if err != nil { - return ScrapeResponse{}, fmt.Errorf("scrape: marshal request: %w", err) - } - - url := c.cfg.BaseURL + "/scrape" - if c.cfg.Token != "" { - url += "?token=" + c.cfg.Token - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return ScrapeResponse{}, fmt.Errorf("scrape: build request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - - resp, err := c.http.Do(httpReq) - if err != nil { - return ScrapeResponse{}, fmt.Errorf("scrape: do request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return ScrapeResponse{}, fmt.Errorf("scrape: unexpected status %d: %s", resp.StatusCode, b) - } - - var result ScrapeResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return ScrapeResponse{}, fmt.Errorf("scrape: decode response: %w", err) - } - return result, nil -} - -func (c *scrapeClient) CDPSession(_ context.Context, _ string, _ CDPSessionFunc) error { - return fmt.Errorf("scrape client does not support CDP; use NewCDPClient") + return fmt.Errorf("content client does not support CDP") } diff --git a/scraper/internal/browser/http.go b/scraper/internal/browser/http.go index 5b9c374..ce4ecdd 100644 --- a/scraper/internal/browser/http.go +++ b/scraper/internal/browser/http.go @@ -1,11 +1,17 @@ package browser import ( + "compress/gzip" "context" "fmt" "io" "net/http" + "net/url" + "os" + "strings" "time" + + "github.com/andybalholm/brotli" ) type httpClient struct { @@ -18,11 +24,41 @@ func NewDirectHTTPClient(cfg Config) BrowserClient { if cfg.Timeout == 0 { cfg.Timeout = 30 * time.Second } - return &httpClient{ - cfg: cfg, - http: &http.Client{Timeout: cfg.Timeout}, - sem: makeSem(cfg.MaxConcurrent), + + transport := http.DefaultTransport.(*http.Transport).Clone() + + // Wire in proxy from environment (HTTP_PROXY / HTTPS_PROXY / NO_PROXY). + // This lets operators route traffic through a residential proxy by simply + // setting HTTPS_PROXY=http://user:pass@proxy-host:port without any code + // changes — the standard approach for bypassing datacenter IP blocks. + if proxyURL := proxyFromEnv(); proxyURL != nil { + transport.Proxy = http.ProxyURL(proxyURL) + } else { + transport.Proxy = http.ProxyFromEnvironment } + + return &httpClient{ + cfg: cfg, + http: &http.Client{ + Timeout: cfg.Timeout, + Transport: transport, + }, + sem: makeSem(cfg.MaxConcurrent), + } +} + +// proxyFromEnv returns an explicit proxy URL if SCRAPER_PROXY is set, otherwise +// nil (and http.ProxyFromEnvironment handles the standard HTTP_PROXY / HTTPS_PROXY). +func proxyFromEnv() *url.URL { + raw := os.Getenv("SCRAPER_PROXY") + if raw == "" { + return nil + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return nil + } + return u } func (c *httpClient) Strategy() Strategy { return StrategyDirect } @@ -37,9 +73,25 @@ func (c *httpClient) GetContent(ctx context.Context, req ContentRequest) (string if err != nil { return "", fmt.Errorf("http: build request: %w", err) } - httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") - httpReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") - httpReq.Header.Set("Accept-Language", "en-US,en;q=0.5") + + // Mimic a real Chrome browser request to reduce bot-detection likelihood. + // These headers match what Chrome 124 sends for a top-level navigation. + httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36") + httpReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7") + httpReq.Header.Set("Accept-Language", "en-US,en;q=0.9") + httpReq.Header.Set("Accept-Encoding", "gzip, deflate, br") + httpReq.Header.Set("Connection", "keep-alive") + httpReq.Header.Set("Upgrade-Insecure-Requests", "1") + httpReq.Header.Set("Sec-Fetch-Dest", "document") + httpReq.Header.Set("Sec-Fetch-Mode", "navigate") + httpReq.Header.Set("Sec-Fetch-Site", "none") + httpReq.Header.Set("Sec-Fetch-User", "?1") + httpReq.Header.Set("Cache-Control", "max-age=0") + + // Set Referer for subsequent page requests (anything that is not the root). + if parsed, pErr := url.Parse(req.URL); pErr == nil && parsed.Path != "" && parsed.Path != "/" { + httpReq.Header.Set("Referer", parsed.Scheme+"://"+parsed.Host+"/") + } resp, err := c.http.Do(httpReq) if err != nil { @@ -52,7 +104,23 @@ func (c *httpClient) GetContent(ctx context.Context, req ContentRequest) (string return "", fmt.Errorf("http: unexpected status %d: %s", resp.StatusCode, b) } - raw, err := io.ReadAll(resp.Body) + // Decompress gzip/br responses when the server honours Accept-Encoding. + // net/http decompresses gzip automatically only when it sets the header + // itself; since we set Accept-Encoding explicitly we must do it ourselves. + body := resp.Body + switch strings.ToLower(resp.Header.Get("Content-Encoding")) { + case "gzip": + gr, gzErr := gzip.NewReader(resp.Body) + if gzErr != nil { + return "", fmt.Errorf("http: gzip reader: %w", gzErr) + } + defer gr.Close() + body = gr + case "br": + body = io.NopCloser(brotli.NewReader(resp.Body)) + } + + raw, err := io.ReadAll(body) if err != nil { return "", fmt.Errorf("http: read body: %w", err) } diff --git a/scraper/internal/e2e/e2e_test.go b/scraper/internal/e2e/e2e_test.go new file mode 100644 index 0000000..0ddd588 --- /dev/null +++ b/scraper/internal/e2e/e2e_test.go @@ -0,0 +1,818 @@ +//go:build integration + +// End-to-end integration test for libnovel. +// +// Scenario (executed in order): +// 1. Health-check all Docker services (PocketBase, MinIO, Browserless, scraper). +// 2. Register a test user in the app_users PocketBase collection. +// 3. Scrape the popular-ranking page 1 and capture the first book. +// 4. Scrape full metadata for that book and persist it; verify in PocketBase. +// 5. Scrape chapters 1–3 and persist them; verify in MinIO + PocketBase. +// 6. Generate TTS audio for the first 100 chars of each chapter via the scraper +// HTTP API; verify MinIO object + PocketBase audio_cache entry. +// 7. Fetch presigned URLs for each chapter's markdown and audio; verify HTTP 200. +// +// Prerequisites (all must be running): +// +// docker-compose up -d minio pocketbase browserless scraper +// +// Run with: +// +// BROWSERLESS_URL=http://localhost:3030 \ +// MINIO_ENDPOINT=localhost:9000 \ +// POCKETBASE_URL=http://localhost:8090 \ +// SCRAPER_URL=http://localhost:8080 \ +// go test -v -tags integration -timeout 900s \ +// github.com/libnovel/scraper/internal/e2e +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/libnovel/scraper/internal/browser" + "github.com/libnovel/scraper/internal/novelfire" + "github.com/libnovel/scraper/internal/scraper" + "github.com/libnovel/scraper/internal/storage" +) + +// ─── env helpers ───────────────────────────────────────────────────────────── + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// ─── fixture ───────────────────────────────────────────────────────────────── + +type e2eFixture struct { + sc *novelfire.Scraper + hs *storage.HybridStore + scraperURL string // base URL of the running scraper HTTP server + pbBaseURL string + pbEmail string + pbPassword string +} + +func newE2EFixture(t *testing.T) *e2eFixture { + t.Helper() + + browserlessURL := envOr("BROWSERLESS_URL", "") + if browserlessURL == "" { + t.Skip("BROWSERLESS_URL not set — skipping e2e test") + } + if os.Getenv("MINIO_ENDPOINT") == "" { + t.Skip("MINIO_ENDPOINT not set — skipping e2e test") + } + if os.Getenv("POCKETBASE_URL") == "" { + t.Skip("POCKETBASE_URL not set — skipping e2e test") + } + scraperURL := envOr("SCRAPER_URL", "http://localhost:8080") + + pbBaseURL := envOr("POCKETBASE_URL", "http://localhost:8090") + pbEmail := envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local") + pbPassword := envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123") + + pbCfg := storage.PocketBaseConfig{ + BaseURL: pbBaseURL, + AdminEmail: pbEmail, + AdminPassword: pbPassword, + } + minioCfg := storage.MinioConfig{ + Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"), + AccessKey: envOr("MINIO_ACCESS_KEY", "admin"), + SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"), + UseSSL: envOr("MINIO_USE_SSL", "false") == "true", + BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"), + BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"), + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg, slog.Default()) + if err != nil { + t.Fatalf("NewHybridStore: %v", err) + } + + // directClient: plain HTTP GET — used for chapter text, metadata, and ranking + // (novelfire.net serves these pages server-side; no JS rendering needed). + directClient := browser.NewDirectHTTPClient(browser.Config{ + Timeout: 60 * time.Second, + MaxConcurrent: 2, + }) + // urlClient: Browserless content strategy — used only for chapter-list + // pagination pages which require JS rendering to populate the list. + urlClient := browser.NewContentClient(browser.Config{ + BaseURL: browserlessURL, + Token: os.Getenv("BROWSERLESS_TOKEN"), + Timeout: 120 * time.Second, + MaxConcurrent: 2, + }) + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) + sc := novelfire.New(directClient, log, urlClient, directClient, nil) + + return &e2eFixture{ + sc: sc, + hs: hs, + scraperURL: scraperURL, + pbBaseURL: pbBaseURL, + pbEmail: pbEmail, + pbPassword: pbPassword, + } +} + +// ─── The single end-to-end test ─────────────────────────────────────────────── + +// TestE2E_FullScenario executes the complete end-to-end scenario in order. +func TestE2E_FullScenario(t *testing.T) { + f := newE2EFixture(t) + + // ── Step 1: Health-check services ──────────────────────────────────────── + t.Run("step1_health_checks", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // PocketBase health + pbHealth := f.pbBaseURL + "/api/health" + checkHTTP(t, ctx, pbHealth, "PocketBase") + + // MinIO health — the MinIO console liveness endpoint + minioEndpoint := envOr("MINIO_ENDPOINT", "localhost:9000") + scheme := "http" + if envOr("MINIO_USE_SSL", "false") == "true" { + scheme = "https" + } + minioHealth := fmt.Sprintf("%s://%s/minio/health/live", scheme, minioEndpoint) + checkHTTP(t, ctx, minioHealth, "MinIO") + + // Browserless health — /pressure is the liveness endpoint + browserlessURL := envOr("BROWSERLESS_URL", "http://localhost:3030") + blHealth := browserlessURL + "/pressure" + checkHTTP(t, ctx, blHealth, "Browserless") + + // Scraper server health — wait up to 10 s for it to be ready + scraperHealth := f.scraperURL + "/health" + waitForHTTP(t, ctx, scraperHealth, "scraper server", 10*time.Second) + }) + + // ── Step 2: Register test user ──────────────────────────────────────────── + var testUsername string + t.Run("step2_register_user", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + testUsername = fmt.Sprintf("e2euser-%d", time.Now().UnixMilli()%100000) + passwordHash := "pbkdf2:sha256:dummy-hash-for-test" + + t.Cleanup(func() { + cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cleanCancel() + deleteAppUser(t, f, cleanCtx, testUsername) + }) + + if err := createAppUser(ctx, f, testUsername, passwordHash, "reader"); err != nil { + t.Fatalf("createAppUser: %v", err) + } + t.Logf("created user %q", testUsername) + + // Verify the user exists in PocketBase. + rec, err := getAppUserByUsername(ctx, f, testUsername) + if err != nil { + t.Fatalf("getAppUserByUsername: %v", err) + } + if rec == nil { + t.Fatal("user not found in app_users after creation") + } + if rec["username"] != testUsername { + t.Errorf("username = %q, want %q", rec["username"], testUsername) + } + t.Logf("user verified in PocketBase: id=%v username=%v role=%v", rec["id"], rec["username"], rec["role"]) + }) + + // ── Step 3: Scrape ranking page 1, capture first book ──────────────────── + var firstBook scraper.BookMeta + t.Run("step3_scrape_ranking", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + entries, errs := f.sc.ScrapeRanking(ctx, 1) // maxPages=1 → only page 1 + + select { + case meta, ok := <-entries: + if !ok { + t.Fatal("ranking channel closed without any entry") + } + firstBook = meta + case err := <-errs: + t.Fatalf("ScrapeRanking error: %v", err) + case <-ctx.Done(): + t.Fatal("ScrapeRanking timed out waiting for first entry") + } + + // Drain remaining entries and errors. + for range entries { + } + for range errs { + } + + if firstBook.Slug == "" { + t.Fatal("first book has empty slug") + } + if firstBook.Title == "" { + t.Fatal("first book has empty title") + } + if firstBook.SourceURL == "" { + t.Fatal("first book has empty SourceURL") + } + t.Logf("first ranked book: slug=%q title=%q rank=%d url=%s", + firstBook.Slug, firstBook.Title, firstBook.Ranking, firstBook.SourceURL) + }) + + if firstBook.Slug == "" || firstBook.SourceURL == "" { + t.Fatal("cannot continue: step3 did not produce a valid first book") + } + + // Use a unique slug for the test to avoid colliding with real scraped data. + testSlug := fmt.Sprintf("%s-e2e-%d", firstBook.Slug, time.Now().UnixMilli()%100000) + t.Logf("using test slug: %q", testSlug) + + // Register cleanup for all data written by subsequent steps. + t.Cleanup(func() { + cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cleanCancel() + cleanupTestData(t, f, cleanCtx, testSlug) + }) + + // ── Step 4: Scrape book metadata and persist ────────────────────────────── + var fullMeta scraper.BookMeta + t.Run("step4_scrape_metadata", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + meta, err := f.sc.ScrapeMetadata(ctx, firstBook.SourceURL) + if err != nil { + t.Fatalf("ScrapeMetadata: %v", err) + } + t.Logf("scraped metadata: title=%q author=%q totalChapters=%d", + meta.Title, meta.Author, meta.TotalChapters) + + // Override slug so data lands under our test slug. + meta.Slug = testSlug + fullMeta = meta + + storeCtx, storeCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer storeCancel() + + if err := f.hs.WriteMetadata(storeCtx, meta); err != nil { + t.Fatalf("WriteMetadata: %v", err) + } + + // Verify in PocketBase. + got, found, err := f.hs.ReadMetadata(storeCtx, testSlug) + if err != nil { + t.Fatalf("ReadMetadata: %v", err) + } + if !found { + t.Fatal("book not found in PocketBase after WriteMetadata") + } + if got.Title == "" { + t.Error("book title is empty after round-trip") + } + if got.Author == "" { + t.Logf("WARNING: book author is empty after round-trip (site may not expose author for this book)") + } + t.Logf("PocketBase verified: title=%q author=%q totalChapters=%d", got.Title, got.Author, got.TotalChapters) + }) + + if fullMeta.SourceURL == "" { + fullMeta.SourceURL = firstBook.SourceURL + } + + // ── Step 5: Scrape first 3 chapters and persist ─────────────────────────── + var chapterRefs []scraper.ChapterRef + t.Run("step5_scrape_chapters", func(t *testing.T) { + // Fetch only page 1 of the chapter list from + // https://novelfire.net/book/{slug}/chapters?page=1 + // to avoid paginating through hundreds of pages for popular books. + listCtx, listCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer listCancel() + + chaptersPageURL := firstBook.SourceURL + "/chapters?page=1" + refs, err := scrapeChapterListPage1(listCtx, f, chaptersPageURL) + if err != nil { + t.Fatalf("scrapeChapterListPage1: %v", err) + } + if len(refs) == 0 { + t.Fatal("chapter list page 1 returned no chapters") + } + t.Logf("chapter list page 1: %d chapters found", len(refs)) + + // Take the first 3 (or fewer if page 1 has < 3 chapters). + n := 3 + if len(refs) < n { + n = len(refs) + } + chapterRefs = refs[:n] + t.Logf("will scrape first %d chapters: %v", n, chapterNumbers(chapterRefs)) + + for _, ref := range chapterRefs { + ref := ref + t.Run(fmt.Sprintf("chapter-%d", ref.Number), func(t *testing.T) { + scrapeCtx, scrapeCancel := context.WithTimeout(context.Background(), 180*time.Second) + defer scrapeCancel() + + ch, err := f.sc.ScrapeChapterText(scrapeCtx, ref) + if err != nil { + t.Fatalf("ScrapeChapterText(%d): %v", ref.Number, err) + } + t.Logf("scraped chapter %d: %d bytes", ref.Number, len(ch.Text)) + if len(ch.Text) < 50 { + t.Errorf("chapter %d text too short (%d bytes)", ref.Number, len(ch.Text)) + } + + // Override ref slug with our test slug. + ch.Ref.Number = ref.Number + ch.Ref.Title = ref.Title + + storeCtx, storeCancel := context.WithTimeout(context.Background(), 20*time.Second) + defer storeCancel() + + if err := f.hs.WriteChapter(storeCtx, testSlug, ch); err != nil { + t.Fatalf("WriteChapter(%d): %v", ref.Number, err) + } + + // Verify in MinIO via ReadChapter. + got, err := f.hs.ReadChapter(storeCtx, testSlug, ref.Number) + if err != nil { + t.Fatalf("ReadChapter(%d): %v", ref.Number, err) + } + if got == "" { + t.Errorf("chapter %d: ReadChapter returned empty content", ref.Number) + } + if !strings.HasPrefix(got, "# ") { + t.Errorf("chapter %d: stored content missing markdown header (got %q)", ref.Number, got[:min(len(got), 80)]) + } + + // Verify PocketBase chapters_idx entry. + idxCtx, idxCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer idxCancel() + count := f.hs.CountChapters(idxCtx, testSlug) + if count == 0 { + t.Errorf("chapter %d: chapters_idx count = 0 after WriteChapter", ref.Number) + } + t.Logf("chapter %d stored; chapters_idx count=%d", ref.Number, count) + }) + } + }) + + if len(chapterRefs) == 0 { + t.Fatal("cannot continue: step5 produced no chapter refs") + } + + // ── Step 6: Generate TTS audio via scraper HTTP API ─────────────────────── + t.Run("step6_tts_audio", func(t *testing.T) { + if os.Getenv("SCRAPER_URL") == "" { + t.Skip("SCRAPER_URL not set — skipping TTS step") + } + + voice := envOr("KOKORO_VOICE", "af_bella") + + for _, ref := range chapterRefs { + ref := ref + t.Run(fmt.Sprintf("audio-chapter-%d", ref.Number), func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + audioURL := fmt.Sprintf("%s/api/audio/%s/%d", f.scraperURL, testSlug, ref.Number) + body, _ := json.Marshal(map[string]interface{}{ + "voice": voice, + "speed": 1.0, + "max_chars": 200, + }) + + audioReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, audioURL, bytes.NewReader(body)) + audioReq.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(audioReq) + if err != nil { + t.Fatalf("POST %s: %v", audioURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("audio generation status=%d body=%s", resp.StatusCode, raw) + } + + var audioResp struct { + URL string `json:"url"` + Filename string `json:"filename"` + } + if err := json.NewDecoder(resp.Body).Decode(&audioResp); err != nil { + t.Fatalf("decode audio response: %v", err) + } + if audioResp.URL == "" { + t.Error("audio response has empty url field") + } + if audioResp.Filename == "" { + t.Error("audio response has empty filename field") + } + t.Logf("chapter %d audio: url=%s filename=%s", ref.Number, audioResp.URL, audioResp.Filename) + + // Verify audio_cache entry exists in PocketBase. + pbCtx, pbCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer pbCancel() + + cacheKey := fmt.Sprintf("%s/%d/%s/1.00", testSlug, ref.Number, voice) + filename, found := f.hs.GetAudioCache(pbCtx, cacheKey) + if !found { + t.Errorf("audio_cache entry not found for key=%q", cacheKey) + } else { + t.Logf("audio_cache[%q] = %q", cacheKey, filename) + } + }) + } + }) + + // ── Step 7: Presigned URLs ──────────────────────────────────────────────── + t.Run("step7_presigned_urls", func(t *testing.T) { + if os.Getenv("SCRAPER_URL") == "" { + t.Skip("SCRAPER_URL not set — skipping presign step") + } + + // Give the background MinIO upload goroutines (launched by handleAudioGenerate) + // a moment to complete before we attempt to access the presigned URLs. + time.Sleep(5 * time.Second) + + voice := envOr("KOKORO_VOICE", "af_bella") + + for _, ref := range chapterRefs { + ref := ref + t.Run(fmt.Sprintf("presign-chapter-%d", ref.Number), func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Chapter markdown presign. + chPresignURL := fmt.Sprintf("%s/api/presign/chapter/%s/%d", + f.scraperURL, testSlug, ref.Number) + chPresigned := fetchPresignedURL(t, ctx, chPresignURL, "chapter presign") + if chPresigned != "" { + assertURLAccessible(t, ctx, chPresigned, fmt.Sprintf("chapter %d presigned URL", ref.Number)) + } + + // Audio presign — poll with retries to allow background MinIO upload to finish. + auPresignURL := fmt.Sprintf("%s/api/presign/audio/%s/%d?voice=%s&speed=1.0", + f.scraperURL, testSlug, ref.Number, voice) + auPresigned := fetchPresignedURL(t, ctx, auPresignURL, "audio presign") + if auPresigned != "" { + assertURLAccessibleWithRetry(t, ctx, auPresigned, fmt.Sprintf("chapter %d audio presigned URL", ref.Number), 6, 5*time.Second) + } + }) + } + }) +} + +// ─── PocketBase admin helpers ───────────────────────────────────────────────── + +// pbAuthToken obtains a PocketBase superuser JWT. +func pbAuthToken(ctx context.Context, f *e2eFixture) (string, error) { + body, _ := json.Marshal(map[string]string{ + "identity": f.pbEmail, + "password": f.pbPassword, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + f.pbBaseURL+"/api/collections/_superusers/auth-with-password", + bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("pbAuthToken: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("pbAuthToken status %d: %s", resp.StatusCode, b) + } + var result struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("pbAuthToken decode: %w", err) + } + return result.Token, nil +} + +// createAppUser inserts a record into app_users via PocketBase admin API. +func createAppUser(ctx context.Context, f *e2eFixture, username, passwordHash, role string) error { + tok, err := pbAuthToken(ctx, f) + if err != nil { + return err + } + payload, _ := json.Marshal(map[string]interface{}{ + "username": username, + "password_hash": passwordHash, + "role": role, + "created": time.Now().UTC().Format(time.RFC3339), + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + f.pbBaseURL+"/api/collections/app_users/records", + bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", tok) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("createAppUser: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("createAppUser status %d: %s", resp.StatusCode, b) + } + return nil +} + +// getAppUserByUsername fetches an app_users record by username. +// Returns nil, nil when not found. +func getAppUserByUsername(ctx context.Context, f *e2eFixture, username string) (map[string]interface{}, error) { + tok, err := pbAuthToken(ctx, f) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/api/collections/app_users/records?filter=username%%3D%%22%s%%22&perPage=1", + f.pbBaseURL, username) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", tok) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("getAppUserByUsername: %w", err) + } + defer resp.Body.Close() + var result struct { + Items []map[string]interface{} `json:"items"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("getAppUserByUsername decode: %w", err) + } + if len(result.Items) == 0 { + return nil, nil + } + return result.Items[0], nil +} + +// deleteAppUser removes app_users records matching username. +func deleteAppUser(t *testing.T, f *e2eFixture, ctx context.Context, username string) { + t.Helper() + tok, err := pbAuthToken(ctx, f) + if err != nil { + t.Logf("deleteAppUser: pbAuthToken error: %v", err) + return + } + // List matching records. + url := fmt.Sprintf("%s/api/collections/app_users/records?filter=username%%3D%%22%s%%22&perPage=10", + f.pbBaseURL, username) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req.Header.Set("Authorization", tok) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Logf("deleteAppUser list error: %v", err) + return + } + defer resp.Body.Close() + var result struct { + Items []map[string]interface{} `json:"items"` + } + _ = json.NewDecoder(resp.Body).Decode(&result) + for _, item := range result.Items { + id, _ := item["id"].(string) + delURL := fmt.Sprintf("%s/api/collections/app_users/records/%s", f.pbBaseURL, id) + delReq, _ := http.NewRequestWithContext(ctx, http.MethodDelete, delURL, nil) + delReq.Header.Set("Authorization", tok) + delResp, _ := http.DefaultClient.Do(delReq) + if delResp != nil { + delResp.Body.Close() + } + } +} + +// cleanupTestData removes all PocketBase + MinIO data for the given slug. +func cleanupTestData(t *testing.T, f *e2eFixture, ctx context.Context, slug string) { + t.Helper() + tok, err := pbAuthToken(ctx, f) + if err != nil { + t.Logf("cleanupTestData: pbAuthToken error: %v", err) + return + } + pbDelete := func(collection, filter string) { + listURL := fmt.Sprintf("%s/api/collections/%s/records?filter=%s&perPage=500", + f.pbBaseURL, collection, filter) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil) + req.Header.Set("Authorization", tok) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Logf("cleanupTestData list %s error: %v", collection, err) + return + } + defer resp.Body.Close() + var result struct { + Items []map[string]interface{} `json:"items"` + } + _ = json.NewDecoder(resp.Body).Decode(&result) + for _, item := range result.Items { + id, _ := item["id"].(string) + delURL := fmt.Sprintf("%s/api/collections/%s/records/%s", f.pbBaseURL, collection, id) + delReq, _ := http.NewRequestWithContext(ctx, http.MethodDelete, delURL, nil) + delReq.Header.Set("Authorization", tok) + delResp, _ := http.DefaultClient.Do(delReq) + if delResp != nil { + delResp.Body.Close() + } + } + } + slugFilter := fmt.Sprintf("slug%%3D%%22%s%%22", slug) + ckFilter := fmt.Sprintf("cache_key%%7E%%22%s%%2F%%22", slug) // cache_key ~ "slug/" + pbDelete("books", slugFilter) + pbDelete("chapters_idx", slugFilter) + pbDelete("audio_cache", ckFilter) + t.Logf("cleanup complete for slug=%q", slug) +} + +// ─── HTTP assertion helpers ─────────────────────────────────────────────────── + +// checkHTTP asserts that a GET to url returns 2xx within the context deadline. +func checkHTTP(t *testing.T, ctx context.Context, url, name string) { + t.Helper() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + t.Errorf("%s health check: build request: %v", name, err) + return + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Errorf("%s health check failed: %v", name, err) + return + } + resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + t.Errorf("%s health check: status %d, want 2xx", name, resp.StatusCode) + return + } + t.Logf("%s health OK (HTTP %d)", name, resp.StatusCode) +} + +// waitForHTTP retries GET url until a 2xx is received or timeout is reached. +func waitForHTTP(t *testing.T, ctx context.Context, url, name string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + var lastErr error + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + t.Errorf("%s: context cancelled while waiting for health", name) + return + default: + } + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + resp, err := http.DefaultClient.Do(req) + if err == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 { + resp.Body.Close() + t.Logf("%s health OK (HTTP %d)", name, resp.StatusCode) + return + } + if resp != nil { + resp.Body.Close() + lastErr = fmt.Errorf("status %d", resp.StatusCode) + } else { + lastErr = err + } + time.Sleep(500 * time.Millisecond) + } + t.Errorf("%s not healthy after %s: %v", name, timeout, lastErr) +} + +// fetchPresignedURL calls the presign endpoint and returns the presigned URL. +// It logs and returns "" on failure (non-fatal) so the caller can decide. +func fetchPresignedURL(t *testing.T, ctx context.Context, presignEndpoint, label string) string { + t.Helper() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, presignEndpoint, nil) + if err != nil { + t.Errorf("fetchPresignedURL %s: %v", label, err) + return "" + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Errorf("fetchPresignedURL %s: %v", label, err) + return "" + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Errorf("fetchPresignedURL %s: status %d body=%s", label, resp.StatusCode, b) + return "" + } + var body struct { + URL string `json:"url"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Errorf("fetchPresignedURL %s decode: %v", label, err) + return "" + } + if body.URL == "" { + t.Errorf("fetchPresignedURL %s: empty url in response", label) + return "" + } + t.Logf("%s presigned URL: %s", label, body.URL) + return body.URL +} + +// assertURLAccessible does a GET to url and asserts HTTP 200. +func assertURLAccessible(t *testing.T, ctx context.Context, url, label string) { + t.Helper() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + t.Errorf("%s: build request: %v", label, err) + return + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Errorf("%s: GET error: %v", label, err) + return + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("%s: status %d, want 200", label, resp.StatusCode) + return + } + t.Logf("%s: HTTP 200 OK", label) +} + +// assertURLAccessibleWithRetry retries GET url up to maxAttempts times with +// interval between attempts, asserting HTTP 200 on any success. +func assertURLAccessibleWithRetry(t *testing.T, ctx context.Context, url, label string, maxAttempts int, interval time.Duration) { + t.Helper() + var lastStatus int + for attempt := 1; attempt <= maxAttempts; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + t.Errorf("%s: build request: %v", label, err) + return + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Logf("%s: attempt %d GET error: %v", label, attempt, err) + } else { + resp.Body.Close() + lastStatus = resp.StatusCode + if resp.StatusCode == http.StatusOK { + t.Logf("%s: HTTP 200 OK (attempt %d)", label, attempt) + return + } + t.Logf("%s: attempt %d status %d", label, attempt, resp.StatusCode) + } + if attempt < maxAttempts { + select { + case <-ctx.Done(): + t.Errorf("%s: context cancelled before success", label) + return + case <-time.After(interval): + } + } + } + t.Errorf("%s: status %d after %d attempts, want 200", label, lastStatus, maxAttempts) +} + +// ─── stdlib helpers ─────────────────────────────────────────────────────────── + +func chapterNumbers(refs []scraper.ChapterRef) []int { + ns := make([]int, len(refs)) + for i, r := range refs { + ns[i] = r.Number + } + return ns +} + +// scrapeChapterListPage1 fetches a single chapter-list page URL via Browserless +// and returns the chapter refs found on that page (no pagination). +// URL should be: https://novelfire.net/book/{slug}/chapters?page=1 +func scrapeChapterListPage1(ctx context.Context, f *e2eFixture, pageURL string) ([]scraper.ChapterRef, error) { + return f.sc.ScrapeChapterListPage(ctx, pageURL) +} diff --git a/scraper/internal/novelfire/integration_test.go b/scraper/internal/novelfire/integration_test.go index 5e91e1a..39f821a 100644 --- a/scraper/internal/novelfire/integration_test.go +++ b/scraper/internal/novelfire/integration_test.go @@ -21,6 +21,7 @@ package novelfire import ( "context" "fmt" + "log/slog" "os" "strings" "testing" @@ -51,7 +52,8 @@ func newIntegrationScraper(t *testing.T) *Scraper { Timeout: 120 * time.Second, MaxConcurrent: 1, }) - return New(client, nil) + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) + return New(client, log, client, nil, nil) } // ── Metadata ────────────────────────────────────────────────────────────────── diff --git a/scraper/internal/novelfire/ranking_test.go b/scraper/internal/novelfire/ranking_test.go index 15611a8..0299b19 100644 --- a/scraper/internal/novelfire/ranking_test.go +++ b/scraper/internal/novelfire/ranking_test.go @@ -2,14 +2,9 @@ package novelfire import ( "context" - "fmt" - "os" - "path/filepath" "testing" - "github.com/libnovel/scraper/internal/browser" "github.com/libnovel/scraper/internal/scraper" - "github.com/libnovel/scraper/internal/writer" ) // rankingPage1HTML is a realistic mock of the popular genre listing page @@ -116,7 +111,7 @@ func TestScrapeRanking_MultiPage(t *testing.T) { // Use pagedStubClient for s.client so each GetContent call returns the // next page. ScrapeRanking now calls s.client directly. urlClient := &pagedStubClient{pages: []string{rankingPage1HTML(), rankingPage2HTML()}} - s := New(urlClient, nil, nil, nil) // nil cache — no disk I/O in tests + s := New(urlClient, nil, nil, nil, nil) // nil cache — no disk I/O in tests entryCh, errCh := s.ScrapeRanking(context.Background(), 0) // 0 = all pages entries := drainRanking(t, entryCh, errCh) @@ -151,146 +146,3 @@ func TestScrapeRanking_EmptyPage(t *testing.T) { t.Errorf("expected 0 entries for empty page, got %d", len(entries)) } } - -// TestWriteRanking_RoundTrip verifies WriteRanking → ReadRankingItems -// faithfully reconstructs the original slice. -func TestWriteRanking_RoundTrip(t *testing.T) { - dir := t.TempDir() - w := writer.New(dir) - - items := []writer.RankingItem{ - {Rank: 1, Slug: "the-iron-throne", Title: "The Iron Throne", Status: "Ongoing", - Genres: []string{"Fantasy", "Action"}, SourceURL: "https://novelfire.net/book/the-iron-throne"}, - {Rank: 2, Slug: "shadow-mage", Title: "Shadow Mage", Status: "Completed", - Genres: []string{"Magic"}, SourceURL: "https://novelfire.net/book/shadow-mage"}, - } - - if err := w.WriteRanking(items); err != nil { - t.Fatalf("WriteRanking failed: %v", err) - } - - rankingFile := filepath.Join(dir, "ranking.json") - if _, err := os.Stat(rankingFile); err != nil { - t.Fatalf("ranking.json not created: %v", err) - } - - got, err := w.ReadRankingItems() - if err != nil { - t.Fatalf("ReadRankingItems failed: %v", err) - } - if len(got) != len(items) { - t.Fatalf("expected %d items, got %d", len(items), len(got)) - } - for i, want := range items { - if got[i].Rank != want.Rank { - t.Errorf("item[%d].Rank = %d, want %d", i, got[i].Rank, want.Rank) - } - if got[i].Slug != want.Slug { - t.Errorf("item[%d].Slug = %q, want %q", i, got[i].Slug, want.Slug) - } - if got[i].Title != want.Title { - t.Errorf("item[%d].Title = %q, want %q", i, got[i].Title, want.Title) - } - if got[i].Status != want.Status { - t.Errorf("item[%d].Status = %q, want %q", i, got[i].Status, want.Status) - } - if len(got[i].Genres) != len(want.Genres) { - t.Errorf("item[%d].Genres len = %d, want %d", i, len(got[i].Genres), len(want.Genres)) - } else { - for j, g := range want.Genres { - if got[i].Genres[j] != g { - t.Errorf("item[%d].Genres[%d] = %q, want %q", i, j, got[i].Genres[j], g) - } - } - } - if got[i].SourceURL != want.SourceURL { - t.Errorf("item[%d].SourceURL = %q, want %q", i, got[i].SourceURL, want.SourceURL) - } - } -} - -// ── in-memory page cacher ───────────────────────────────────────────────────── - -// memPageCacher is a RankingPageCacher backed by an in-memory map. -// It records how many times each page was written and exposes the stored HTML. -type memPageCacher struct { - pages map[int]string - writes map[int]int -} - -func newMemPageCacher() *memPageCacher { - return &memPageCacher{pages: make(map[int]string), writes: make(map[int]int)} -} - -func (c *memPageCacher) WriteRankingPageCache(page int, html string) error { - c.pages[page] = html - c.writes[page]++ - return nil -} - -func (c *memPageCacher) ReadRankingPageCache(page int) (string, error) { - return c.pages[page], nil // returns "" on miss, satisfying the interface contract -} - -var _ scraper.RankingPageCacher = (*memPageCacher)(nil) // compile-time check - -// TestScrapeRanking_CacheHit verifies that when a page is already in the cache -// ScrapeRanking serves from cache and does NOT call the browser client. -func TestScrapeRanking_CacheHit(t *testing.T) { - cache := newMemPageCacher() - // Pre-populate the cache with page 1 HTML. - if err := cache.WriteRankingPageCache(1, rankingPage1HTML()); err != nil { - t.Fatalf("cache write: %v", err) - } - cache.writes[1] = 0 // reset write counter — we only care about fetches - - // The stub client panics on any GetContent call so we can prove it is not used. - panicClient := &panicOnGetContent{} - s := New(panicClient, nil, panicClient, cache) - - entryCh, errCh := s.ScrapeRanking(context.Background(), 1) - entries := drainRanking(t, entryCh, errCh) - - if len(entries) != 2 { - t.Fatalf("expected 2 entries from cache, got %d", len(entries)) - } - // Cache should not have been written again (we served from cache). - if cache.writes[1] != 0 { - t.Errorf("expected 0 cache writes on a hit, got %d", cache.writes[1]) - } -} - -// TestScrapeRanking_CacheMiss verifies that on a cache miss the page is fetched -// from the network and the result is written to the cache. -func TestScrapeRanking_CacheMiss(t *testing.T) { - cache := newMemPageCacher() // empty cache - s := New(&stubClient{html: rankingPage1HTML()}, nil, nil, cache) - - entryCh, errCh := s.ScrapeRanking(context.Background(), 1) - entries := drainRanking(t, entryCh, errCh) - - if len(entries) != 2 { - t.Fatalf("expected 2 entries, got %d", len(entries)) - } - if cache.writes[1] != 1 { - t.Errorf("expected 1 cache write on a miss, got %d", cache.writes[1]) - } - if cache.pages[1] == "" { - t.Error("expected page 1 to be stored in cache after miss") - } -} - -// panicOnGetContent is a BrowserClient whose GetContent panics, letting tests -// assert that it is never called (i.e. the cache was used instead). -type panicOnGetContent struct{} - -func (p *panicOnGetContent) Strategy() browser.Strategy { return browser.StrategyContent } -func (p *panicOnGetContent) GetContent(_ context.Context, req browser.ContentRequest) (string, error) { - panic(fmt.Sprintf("unexpected GetContent call for URL %s — should have been served from cache", req.URL)) -} -func (p *panicOnGetContent) ScrapePage(_ context.Context, _ browser.ScrapeRequest) (browser.ScrapeResponse, error) { - return browser.ScrapeResponse{}, nil -} -func (p *panicOnGetContent) CDPSession(_ context.Context, _ string, _ browser.CDPSessionFunc) error { - return nil -} diff --git a/scraper/internal/novelfire/scraper.go b/scraper/internal/novelfire/scraper.go index 2fc1445..c03e86d 100644 --- a/scraper/internal/novelfire/scraper.go +++ b/scraper/internal/novelfire/scraper.go @@ -30,46 +30,38 @@ const ( rankingPath = "/genre-all/sort-popular/status-all/all-novel" ) -// rejectResourceTypes lists Browserless resource types to block on every request. -// We keep: document (the page), script (JS renders the DOM), fetch/xhr (JS data calls). -// Everything else is safe to drop for HTML-only scraping. -var rejectResourceTypes = []string{ - "cspviolationreport", - "eventsource", - "fedcm", - "font", - "image", - "manifest", - "media", - "other", - "ping", - "signedexchange", - "stylesheet", - "texttrack", - "websocket", +// RankingStore is the subset of storage.Store consumed by ScrapeRanking. +type RankingStore interface { + WriteRankingItem(ctx context.Context, item scraper.RankingItem) error + RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error) } // Scraper is the novelfire.net implementation of scraper.NovelScraper. -// It uses the /content strategy by default (rendered HTML via Browserless). +// It uses direct HTTP requests (no headless browser required). type Scraper struct { - client browser.BrowserClient - urlClient browser.BrowserClient // separate client for URL retrieval (uses browserless content strategy) - pageCache scraper.RankingPageCacher - log *slog.Logger + client browser.BrowserClient + urlClient browser.BrowserClient // used for chapter list pagination + chapterClient browser.BrowserClient // used for chapter text fetching + rankingStore RankingStore + log *slog.Logger } // New returns a new novelfire Scraper. -// client is used for content fetching, urlClient is used for URL retrieval (chapter list). -// If urlClient is nil, client will be used for both. -// pageCache is optional; pass nil to disable ranking page caching. -func New(client browser.BrowserClient, log *slog.Logger, urlClient browser.BrowserClient, pageCache scraper.RankingPageCacher) *Scraper { +// client is used for catalogue/metadata/ranking fetching (direct HTTP). +// urlClient is used for chapter list pagination; falls back to client if nil. +// chapterClient is used for chapter text fetching; falls back to client if nil. +// rankingStore is optional; pass nil to disable freshness checks and per-item persistence. +func New(client browser.BrowserClient, log *slog.Logger, urlClient browser.BrowserClient, chapterClient browser.BrowserClient, rankingStore RankingStore) *Scraper { if log == nil { log = slog.Default() } if urlClient == nil { urlClient = client } - return &Scraper{client: client, urlClient: urlClient, pageCache: pageCache, log: log} + if chapterClient == nil { + chapterClient = client + } + return &Scraper{client: client, urlClient: urlClient, chapterClient: chapterClient, rankingStore: rankingStore, log: log} } // SourceName implements NovelScraper. @@ -97,18 +89,9 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan scraper.Catalogue } s.log.Info("scraping catalogue page", "page", page, "url", pageURL) - s.log.Debug("catalogue page fetch starting", - "page", page, - "payload_url", pageURL, - "payload_wait_selector", ".novel-item", - "payload_wait_selector_timeout_ms", 5000, - ) html, err := s.client.GetContent(ctx, browser.ContentRequest{ - URL: pageURL, - WaitFor: &browser.WaitForSelector{Selector: ".novel-item", Timeout: 5000}, - RejectResourceTypes: rejectResourceTypes, - GotoOptions: &browser.GotoOptions{Timeout: 60000}, + URL: pageURL, }) if err != nil { s.log.Debug("catalogue page fetch failed", @@ -131,24 +114,28 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan scraper.Catalogue return } - // Extract novel cards:
    - cards := htmlutil.FindAll(root, scraper.Selector{Tag: "div", Class: "novel-item", Multiple: true}) + // Extract novel cards:
  • + // + //
    + //

    Title

    + //
    + cards := htmlutil.FindAll(root, scraper.Selector{Tag: "li", Class: "novel-item", Multiple: true}) if len(cards) == 0 { s.log.Warn("no novel cards found, stopping pagination", "page", page) return } for _, card := range cards { - // Title:

    Title - titleNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "h3", Class: "novel-title"}) + // The outer carries the href;

    has the title text. + linkNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "a", Attr: "href"}) + titleNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "h4", Class: "novel-title"}) var title, href string + if linkNode != nil { + href = htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "href"}) + } if titleNode != nil { - linkNode := htmlutil.FindFirst(titleNode, scraper.Selector{Tag: "a", Attr: "href"}) - if linkNode != nil { - title = htmlutil.ExtractText(linkNode, scraper.Selector{}) - href = htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "href"}) - } + title = strings.TrimSpace(htmlutil.ExtractText(titleNode, scraper.Selector{})) } if href == "" || title == "" { continue @@ -162,8 +149,17 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan scraper.Catalogue } } - // Find next page link: Jane Doe +
    + Ongoing + +

    A sweeping epic set in a magical world.

    + 42 Chapters + ` + + s := newScraper(html) + meta, err := s.ScrapeMetadata(context.Background(), "https://novelfire.net/book/the-iron-throne") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if meta.Slug != "the-iron-throne" { + t.Errorf("Slug = %q, want %q", meta.Slug, "the-iron-throne") + } + if meta.Title != "The Iron Throne" { + t.Errorf("Title = %q, want %q", meta.Title, "The Iron Throne") + } + if meta.Author != "Jane Doe" { + t.Errorf("Author = %q, want %q", meta.Author, "Jane Doe") + } + if meta.Cover != "https://cdn.example.com/cover.jpg" { + t.Errorf("Cover = %q, want %q", meta.Cover, "https://cdn.example.com/cover.jpg") + } + if meta.Status != "Ongoing" { + t.Errorf("Status = %q, want %q", meta.Status, "Ongoing") + } + if len(meta.Genres) != 2 || meta.Genres[0] != "Fantasy" || meta.Genres[1] != "Action" { + t.Errorf("Genres = %v, want [Fantasy Action]", meta.Genres) + } + if !strings.Contains(meta.Summary, "sweeping epic") { + t.Errorf("Summary = %q, want it to contain 'sweeping epic'", meta.Summary) + } + if meta.TotalChapters != 42 { + t.Errorf("TotalChapters = %d, want 42", meta.TotalChapters) + } +} + +func TestScrapeMetadata_RelativeCoverURL(t *testing.T) { + html := ` +

    Relative Cover

    +
    + ` + + s := newScraper(html) + meta, err := s.ScrapeMetadata(context.Background(), "https://novelfire.net/book/relative-cover") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Relative cover URL should be resolved against the base domain. + if !strings.HasPrefix(meta.Cover, "https://novelfire.net") { + t.Errorf("Cover = %q, expected it to be resolved to an absolute URL", meta.Cover) + } +} + +func TestScrapeMetadata_MissingFields(t *testing.T) { + // Minimal page — everything absent; should succeed without panicking. + html := `` + + s := newScraper(html) + meta, err := s.ScrapeMetadata(context.Background(), "https://novelfire.net/book/empty-novel") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if meta.Slug != "empty-novel" { + t.Errorf("Slug = %q, want %q", meta.Slug, "empty-novel") + } + if meta.TotalChapters != 0 { + t.Errorf("TotalChapters = %d, want 0 for missing chapter-count", meta.TotalChapters) + } +} + // ── ScrapeChapterList (position vs URL numbering) ───────────────────────────── // TestScrapeChapterList_NumbersFromURL verifies that when the chapter list HTML diff --git a/scraper/internal/orchestrator/orchestrator.go b/scraper/internal/orchestrator/orchestrator.go index d35a6c0..55b97d0 100644 --- a/scraper/internal/orchestrator/orchestrator.go +++ b/scraper/internal/orchestrator/orchestrator.go @@ -17,36 +17,58 @@ import ( "log/slog" "runtime" "sync" + "sync/atomic" "github.com/libnovel/scraper/internal/scraper" - "github.com/libnovel/scraper/internal/writer" + "github.com/libnovel/scraper/internal/storage" ) +// Progress is a snapshot of counters at a point in time. +type Progress struct { + BooksFound int + ChaptersScraped int + ChaptersSkipped int + Errors int +} + // Config holds tunable parameters for the orchestrator. type Config struct { // Workers is the number of goroutines used to scrape chapters in parallel. // Defaults to runtime.NumCPU() when 0. Workers int - // StaticRoot is the path to the static/books output directory. + // StaticRoot is kept for backwards-compatibility but is no longer used + // when a Store is provided. StaticRoot string // SingleBookURL when non-empty causes the orchestrator to scrape only // that one book instead of walking the full catalogue. SingleBookURL string + + // FromChapter, when > 0, skips chapters with number < FromChapter. + // Only effective in single-book mode. + FromChapter int + + // ToChapter, when > 0, skips chapters with number > ToChapter. + // Only effective in single-book mode. 0 means "no upper limit". + ToChapter int + + // OnProgress is called periodically with the current progress counters. + // It is always called on completion (success or failure). May be nil. + OnProgress func(p Progress) } // Orchestrator coordinates the full scrape pipeline. type Orchestrator struct { cfg Config novel scraper.NovelScraper - writer *writer.Writer + store storage.Store log *slog.Logger workers int } -// New returns a new Orchestrator. -func New(cfg Config, novel scraper.NovelScraper, log *slog.Logger) *Orchestrator { +// New returns a new Orchestrator backed by the provided Store. +func New(cfg Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store) *Orchestrator { workers := cfg.Workers if workers <= 0 { workers = runtime.NumCPU() @@ -54,7 +76,7 @@ func New(cfg Config, novel scraper.NovelScraper, log *slog.Logger) *Orchestrator return &Orchestrator{ cfg: cfg, novel: novel, - writer: writer.New(cfg.StaticRoot), + store: store, log: log, workers: workers, } @@ -66,9 +88,31 @@ func (o *Orchestrator) Run(ctx context.Context) error { o.log.Info("orchestrator starting", "source", o.novel.SourceName(), "workers", o.workers, - "static_root", o.cfg.StaticRoot, ) + // Atomic counters updated by concurrent goroutines. + var ( + booksFound atomic.Int64 + chaptersScraped atomic.Int64 + chaptersSkipped atomic.Int64 + errors atomic.Int64 + ) + + snapshot := func() Progress { + return Progress{ + BooksFound: int(booksFound.Load()), + ChaptersScraped: int(chaptersScraped.Load()), + ChaptersSkipped: int(chaptersSkipped.Load()), + Errors: int(errors.Load()), + } + } + + notify := func() { + if o.cfg.OnProgress != nil { + o.cfg.OnProgress(snapshot()) + } + } + // chapterWork is the shared queue consumed by chapter worker goroutines. type chapterJob struct { slug string @@ -89,10 +133,12 @@ func (o *Orchestrator) Run(ctx context.Context) error { default: } - // Skip if already on disk. - if o.writer.ChapterExists(job.slug, job.ref) { + // Skip if already stored. + if o.store.ChapterExists(ctx, job.slug, job.ref) { o.log.Debug("chapter already exists, skipping", "book", job.slug, "chapter", job.ref.Number) + chaptersSkipped.Add(1) + notify() continue } @@ -104,18 +150,24 @@ func (o *Orchestrator) Run(ctx context.Context) error { "url", job.ref.URL, "err", err, ) + errors.Add(1) + notify() continue } - if err := o.writer.WriteChapter(job.slug, chapter); err != nil { + if err := o.store.WriteChapter(ctx, job.slug, chapter); err != nil { o.log.Error("chapter write failed", "book", job.slug, "chapter", job.ref.Number, "err", err, ) + errors.Add(1) + notify() continue } + chaptersScraped.Add(1) + notify() o.log.Info("chapter saved", "book", job.slug, "chapter", job.ref.Number, @@ -132,21 +184,27 @@ func (o *Orchestrator) Run(ctx context.Context) error { meta, err := o.novel.ScrapeMetadata(ctx, bookURL) if err != nil { o.log.Error("metadata scrape failed", "url", bookURL, "err", err) + errors.Add(1) + notify() return } - // Persist / update metadata.yaml. - if err := o.writer.WriteMetadata(meta); err != nil { + // Persist / update metadata. + if err := o.store.WriteMetadata(ctx, meta); err != nil { o.log.Error("metadata write failed", "slug", meta.Slug, "err", err) // Continue — chapters can still be scraped. } + booksFound.Add(1) + notify() o.log.Info("metadata saved", "slug", meta.Slug, "title", meta.Title) // Fetch chapter list. refs, err := o.novel.ScrapeChapterList(ctx, bookURL) if err != nil { o.log.Error("chapter list scrape failed", "slug", meta.Slug, "err", err) + errors.Add(1) + notify() return } @@ -154,6 +212,15 @@ func (o *Orchestrator) Run(ctx context.Context) error { // Enqueue chapter jobs. for _, ref := range refs { + // Apply chapter range filter (only in single-book mode when set). + if o.cfg.FromChapter > 0 && ref.Number < o.cfg.FromChapter { + chaptersSkipped.Add(1) + continue + } + if o.cfg.ToChapter > 0 && ref.Number > o.cfg.ToChapter { + chaptersSkipped.Add(1) + continue + } select { case <-ctx.Done(): return @@ -174,14 +241,17 @@ func (o *Orchestrator) Run(ctx context.Context) error { go func() { for err := range catErrs { o.log.Error("catalogue error", "err", err) + errors.Add(1) + notify() } }() var bookWG sync.WaitGroup + bookLoop: for entry := range entries { select { case <-ctx.Done(): - break + break bookLoop default: } @@ -204,6 +274,9 @@ func (o *Orchestrator) Run(ctx context.Context) error { // Wait for all in-flight chapter scrapes to finish. chapterWG.Wait() + // Final progress notification. + notify() + if ctx.Err() != nil { return fmt.Errorf("orchestrator: context cancelled: %w", ctx.Err()) } diff --git a/scraper/internal/orchestrator/orchestrator_test.go b/scraper/internal/orchestrator/orchestrator_test.go new file mode 100644 index 0000000..8d2324a --- /dev/null +++ b/scraper/internal/orchestrator/orchestrator_test.go @@ -0,0 +1,336 @@ +package orchestrator + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/libnovel/scraper/internal/scraper" + "github.com/libnovel/scraper/internal/storage" + "io" + "log/slog" +) + +// ── mock NovelScraper ───────────────────────────────────────────────────────── + +type mockScraper struct { + catalogue []scraper.CatalogueEntry + meta scraper.BookMeta + metaErr error + chapters []scraper.ChapterRef + chapterTextFn func(ref scraper.ChapterRef) (scraper.Chapter, error) +} + +func (m *mockScraper) SourceName() string { return "mock" } + +func (m *mockScraper) ScrapeCatalogue(_ context.Context) (<-chan scraper.CatalogueEntry, <-chan error) { + entries := make(chan scraper.CatalogueEntry, len(m.catalogue)) + errs := make(chan error, 1) + for _, e := range m.catalogue { + entries <- e + } + close(entries) + close(errs) + return entries, errs +} + +func (m *mockScraper) ScrapeMetadata(_ context.Context, _ string) (scraper.BookMeta, error) { + return m.meta, m.metaErr +} + +func (m *mockScraper) ScrapeChapterList(_ context.Context, _ string) ([]scraper.ChapterRef, error) { + return m.chapters, nil +} + +func (m *mockScraper) ScrapeChapterText(_ context.Context, ref scraper.ChapterRef) (scraper.Chapter, error) { + if m.chapterTextFn != nil { + return m.chapterTextFn(ref) + } + return scraper.Chapter{Ref: ref, Text: "stub text"}, nil +} + +func (m *mockScraper) ScrapeRanking(_ context.Context, _ int) (<-chan scraper.BookMeta, <-chan error) { + ch := make(chan scraper.BookMeta) + errs := make(chan error) + close(ch) + close(errs) + return ch, errs +} + +// ── mock Store ──────────────────────────────────────────────────────────────── + +// mockStore records which methods were called; only implements what the +// orchestrator touches. All other methods panic so unexpected calls surface +// as test failures rather than silent no-ops. +type mockStore struct { + mu sync.Mutex + writtenMeta []scraper.BookMeta + writtenChapters []scraper.Chapter + existingSlugs map[string]map[int]bool // slug → chapterNum → exists +} + +func newMockStore() *mockStore { + return &mockStore{existingSlugs: make(map[string]map[int]bool)} +} + +func (s *mockStore) ChapterExists(_ context.Context, slug string, ref scraper.ChapterRef) bool { + s.mu.Lock() + defer s.mu.Unlock() + if m, ok := s.existingSlugs[slug]; ok { + return m[ref.Number] + } + return false +} + +func (s *mockStore) WriteChapter(_ context.Context, slug string, ch scraper.Chapter) error { + s.mu.Lock() + defer s.mu.Unlock() + s.writtenChapters = append(s.writtenChapters, ch) + return nil +} + +func (s *mockStore) WriteChapterRefs(_ context.Context, _ string, _ []scraper.ChapterRef) error { + return nil +} + +func (s *mockStore) WriteMetadata(_ context.Context, meta scraper.BookMeta) error { + s.mu.Lock() + defer s.mu.Unlock() + s.writtenMeta = append(s.writtenMeta, meta) + return nil +} + +// Unimplemented Store methods — panic so accidental calls surface immediately. +func (s *mockStore) ReadMetadata(_ context.Context, _ string) (scraper.BookMeta, bool, error) { + panic("ReadMetadata not expected") +} +func (s *mockStore) ListBooks(_ context.Context) ([]scraper.BookMeta, error) { + panic("ListBooks not expected") +} +func (s *mockStore) LocalSlugs(_ context.Context) (map[string]bool, error) { + panic("LocalSlugs not expected") +} +func (s *mockStore) MetadataMtime(_ context.Context, _ string) int64 { return 0 } +func (s *mockStore) ReadChapter(_ context.Context, _ string, _ int) (string, error) { + panic("ReadChapter not expected") +} +func (s *mockStore) ListChapters(_ context.Context, _ string) ([]storage.ChapterInfo, error) { + panic("ListChapters not expected") +} +func (s *mockStore) CountChapters(_ context.Context, _ string) int { return 0 } +func (s *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { + panic("ReindexChapters not expected") +} +func (s *mockStore) WriteRankingItem(_ context.Context, _ storage.RankingItem) error { return nil } +func (s *mockStore) ReadRankingItems(_ context.Context) ([]storage.RankingItem, error) { + return nil, nil +} +func (s *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool, error) { + return false, nil +} +func (s *mockStore) GetAudioCache(_ context.Context, _ string) (string, bool) { return "", false } +func (s *mockStore) SetAudioCache(_ context.Context, _, _ string) error { return nil } +func (s *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil } +func (s *mockStore) GetProgress(_ context.Context, _, _ string) (storage.ReadingProgress, bool) { + return storage.ReadingProgress{}, false +} +func (s *mockStore) SetProgress(_ context.Context, _ string, _ storage.ReadingProgress) error { + return nil +} +func (s *mockStore) AllProgress(_ context.Context, _ string) ([]storage.ReadingProgress, error) { + return nil, nil +} +func (s *mockStore) DeleteProgress(_ context.Context, _, _ string) error { return nil } +func (s *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" } + +func (s *mockStore) AudioExists(_ context.Context, _ string) bool { return false } +func (s *mockStore) PresignChapter(_ context.Context, _ string, _ int, _ time.Duration) (string, error) { + return "", nil +} +func (s *mockStore) PresignAudio(_ context.Context, _ string, _ time.Duration) (string, error) { + return "", nil +} +func (s *mockStore) PresignAvatarUpload(_ context.Context, _, _ string) (string, string, error) { + return "", "", nil +} +func (s *mockStore) PresignAvatarURL(_ context.Context, _ string) (string, bool, error) { + return "", false, nil +} +func (s *mockStore) DeleteAvatar(_ context.Context, _ string) error { return nil } +func (s *mockStore) SaveBrowsePage(_ context.Context, _, _ string) error { return nil } +func (s *mockStore) GetBrowsePage(_ context.Context, _ string) (string, bool, error) { + return "", false, nil +} +func (s *mockStore) BrowseHTMLKey(_ string, _ int) string { return "" } +func (s *mockStore) BrowseFilteredHTMLKey(_ string, _ int, _, _, _ string) string { return "" } +func (s *mockStore) BrowseCoverKey(_, _ string) string { return "" } +func (s *mockStore) SaveBrowseAsset(_ context.Context, _ string, _ []byte, _ string) error { + return nil +} +func (s *mockStore) GetBrowseAsset(_ context.Context, _ string) ([]byte, string, bool, error) { + return nil, "", false, nil +} +func (s *mockStore) CreateScrapeTask(_ context.Context, _, _ string) (string, error) { + return "task-id", nil +} +func (s *mockStore) UpdateScrapeTask(_ context.Context, _ string, _ storage.ScrapeTaskUpdate) error { + return nil +} +func (s *mockStore) ListScrapeTasks(_ context.Context) ([]storage.ScrapeTask, error) { + return nil, nil +} +func (s *mockStore) CreateAudioJob(_ context.Context, _ string, _ int, _ string) (string, error) { + return "audio-job-id", nil +} +func (s *mockStore) UpdateAudioJob(_ context.Context, _, _, _ string, _ time.Time) error { + return nil +} +func (s *mockStore) GetAudioJob(_ context.Context, _ string) (storage.AudioJob, bool, error) { + return storage.AudioJob{}, false, nil +} +func (s *mockStore) ListAudioJobs(_ context.Context) ([]storage.AudioJob, error) { + return nil, nil +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +// TestRun_SingleBook verifies the happy-path single-book scrape: metadata is +// persisted and all chapters are written to the store. +func TestRun_SingleBook(t *testing.T) { + novel := &mockScraper{ + meta: scraper.BookMeta{Slug: "the-iron-throne", Title: "The Iron Throne"}, + chapters: []scraper.ChapterRef{ + {Number: 1, Title: "Chapter 1", URL: "https://example.com/book/ch-1"}, + {Number: 2, Title: "Chapter 2", URL: "https://example.com/book/ch-2"}, + {Number: 3, Title: "Chapter 3", URL: "https://example.com/book/ch-3"}, + }, + } + store := newMockStore() + + o := New(Config{Workers: 2, SingleBookURL: "https://example.com/book/the-iron-throne"}, novel, discardLogger(), store) + if err := o.Run(context.Background()); err != nil { + t.Fatalf("Run() returned error: %v", err) + } + + store.mu.Lock() + defer store.mu.Unlock() + + if len(store.writtenMeta) != 1 { + t.Errorf("writtenMeta count = %d, want 1", len(store.writtenMeta)) + } + if len(store.writtenChapters) != 3 { + t.Errorf("writtenChapters count = %d, want 3", len(store.writtenChapters)) + } +} + +// TestRun_SingleBook_SkipsExistingChapters verifies that chapters already in +// the store are not re-scraped. +func TestRun_SingleBook_SkipsExistingChapters(t *testing.T) { + novel := &mockScraper{ + meta: scraper.BookMeta{Slug: "test-novel", Title: "Test Novel"}, + chapters: []scraper.ChapterRef{ + {Number: 1, Title: "Chapter 1"}, + {Number: 2, Title: "Chapter 2"}, + }, + } + store := newMockStore() + // Mark chapter 1 as already existing. + store.existingSlugs["test-novel"] = map[int]bool{1: true} + + o := New(Config{Workers: 1, SingleBookURL: "https://example.com/book/test-novel"}, novel, discardLogger(), store) + if err := o.Run(context.Background()); err != nil { + t.Fatalf("Run() returned error: %v", err) + } + + store.mu.Lock() + defer store.mu.Unlock() + + // Only chapter 2 should have been written; chapter 1 was skipped. + if len(store.writtenChapters) != 1 { + t.Errorf("writtenChapters count = %d, want 1 (skipped ch1)", len(store.writtenChapters)) + } + if store.writtenChapters[0].Ref.Number != 2 { + t.Errorf("expected chapter 2 to be written, got chapter %d", store.writtenChapters[0].Ref.Number) + } +} + +// TestRun_CatalogueMode verifies that catalogue mode processes all books. +func TestRun_CatalogueMode(t *testing.T) { + novel := &mockScraper{ + catalogue: []scraper.CatalogueEntry{ + {Title: "Book A", URL: "https://example.com/book/a"}, + {Title: "Book B", URL: "https://example.com/book/b"}, + }, + meta: scraper.BookMeta{Slug: "book-slug", Title: "A Book"}, + chapters: []scraper.ChapterRef{{Number: 1, Title: "Chapter 1"}}, + } + store := newMockStore() + + o := New(Config{Workers: 2}, novel, discardLogger(), store) + if err := o.Run(context.Background()); err != nil { + t.Fatalf("Run() returned error: %v", err) + } + + store.mu.Lock() + defer store.mu.Unlock() + + // 2 books → 2 metadata writes, 2 chapter writes (one chapter per book). + if len(store.writtenMeta) != 2 { + t.Errorf("writtenMeta count = %d, want 2", len(store.writtenMeta)) + } + if len(store.writtenChapters) != 2 { + t.Errorf("writtenChapters count = %d, want 2", len(store.writtenChapters)) + } +} + +// TestRun_OnProgress_Called verifies that the OnProgress callback fires at +// least once upon completion. +func TestRun_OnProgress_Called(t *testing.T) { + novel := &mockScraper{ + meta: scraper.BookMeta{Slug: "progress-book", Title: "Progress Book"}, + chapters: []scraper.ChapterRef{{Number: 1, Title: "Chapter 1"}}, + } + store := newMockStore() + + var callCount int + o := New(Config{ + Workers: 1, + SingleBookURL: "https://example.com/book/progress-book", + OnProgress: func(_ Progress) { + callCount++ + }, + }, novel, discardLogger(), store) + + if err := o.Run(context.Background()); err != nil { + t.Fatalf("Run() returned error: %v", err) + } + if callCount == 0 { + t.Error("OnProgress was never called") + } +} + +// TestRun_ContextCancelled verifies that Run returns a non-nil error when the +// context is cancelled before work completes. +func TestRun_ContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + novel := &mockScraper{ + meta: scraper.BookMeta{Slug: "cancel-book", Title: "Cancel Book"}, + chapters: []scraper.ChapterRef{{Number: 1}}, + } + store := newMockStore() + + o := New(Config{Workers: 1, SingleBookURL: "https://example.com/book/cancel-book"}, novel, discardLogger(), store) + err := o.Run(ctx) + if err == nil { + t.Error("expected non-nil error when context is cancelled, got nil") + } +} diff --git a/scraper/internal/scraper/htmlutil/htmlutil.go b/scraper/internal/scraper/htmlutil/htmlutil.go index 7a25980..202cee8 100644 --- a/scraper/internal/scraper/htmlutil/htmlutil.go +++ b/scraper/internal/scraper/htmlutil/htmlutil.go @@ -3,6 +3,7 @@ package htmlutil import ( + "net/url" "regexp" "strings" @@ -10,6 +11,24 @@ import ( "golang.org/x/net/html" ) +// ResolveURL returns an absolute URL. If href is already absolute it is +// returned unchanged. Otherwise it is resolved against base using standard +// URL resolution (handles relative paths, absolute paths, etc.). +func ResolveURL(base, href string) string { + if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") { + return href + } + b, err := url.Parse(base) + if err != nil { + return base + href + } + ref, err := url.Parse(href) + if err != nil { + return base + href + } + return b.ResolveReference(ref).String() +} + // ParseHTML parses raw HTML and returns the root node. func ParseHTML(raw string) (*html.Node, error) { return html.Parse(strings.NewReader(raw)) @@ -48,8 +67,8 @@ matched: return true } -// attrVal returns the value of attribute key from node n. -func attrVal(n *html.Node, key string) string { +// AttrVal returns the value of attribute key from node n. +func AttrVal(n *html.Node, key string) string { for _, a := range n.Attr { if a.Key == key { return a.Val @@ -58,8 +77,11 @@ func attrVal(n *html.Node, key string) string { return "" } -// textContent returns the concatenated text content of all descendant text nodes. -func textContent(n *html.Node) string { +// attrVal is an unexported alias kept for internal use within this package. +func attrVal(n *html.Node, key string) string { return AttrVal(n, key) } + +// TextContent returns the concatenated text content of all descendant text nodes. +func TextContent(n *html.Node) string { var sb strings.Builder var walk func(*html.Node) walk = func(cur *html.Node) { @@ -74,6 +96,9 @@ func textContent(n *html.Node) string { return strings.TrimSpace(sb.String()) } +// textContent is an unexported alias kept for internal use within this package. +func textContent(n *html.Node) string { return TextContent(n) } + // FindFirst returns the first node matching sel within root. func FindFirst(root *html.Node, sel scraper.Selector) *html.Node { var found *html.Node diff --git a/scraper/internal/scraper/htmlutil/htmlutil_test.go b/scraper/internal/scraper/htmlutil/htmlutil_test.go new file mode 100644 index 0000000..d9356a3 --- /dev/null +++ b/scraper/internal/scraper/htmlutil/htmlutil_test.go @@ -0,0 +1,221 @@ +package htmlutil + +import ( + "strings" + "testing" + + "github.com/libnovel/scraper/internal/scraper" +) + +// ── ResolveURL ──────────────────────────────────────────────────────────────── + +func TestResolveURL(t *testing.T) { + cases := []struct{ base, href, want string }{ + // Already absolute → unchanged. + {"https://example.com", "https://other.com/page", "https://other.com/page"}, + {"https://example.com", "http://other.com/page", "http://other.com/page"}, + // Absolute path. + {"https://example.com", "/book/slug", "https://example.com/book/slug"}, + // Relative path. + {"https://example.com/genre/all", "page?p=2", "https://example.com/genre/page?p=2"}, + // Empty href → base itself. + {"https://example.com", "", "https://example.com"}, + } + for _, c := range cases { + got := ResolveURL(c.base, c.href) + if got != c.want { + t.Errorf("ResolveURL(%q, %q) = %q, want %q", c.base, c.href, got, c.want) + } + } +} + +// ── AttrVal ─────────────────────────────────────────────────────────────────── + +func TestAttrVal(t *testing.T) { + root, err := ParseHTML(`text`) + if err != nil { + t.Fatal(err) + } + a := FindFirst(root, scraper.Selector{Tag: "a"}) + if a == nil { + t.Fatal("expected to find ") + } + if got := AttrVal(a, "href"); got != "/book/slug" { + t.Errorf("AttrVal href = %q, want %q", got, "/book/slug") + } + if got := AttrVal(a, "class"); got != "link" { + t.Errorf("AttrVal class = %q, want %q", got, "link") + } + if got := AttrVal(a, "missing"); got != "" { + t.Errorf("AttrVal missing = %q, want empty", got) + } +} + +// ── TextContent ─────────────────────────────────────────────────────────────── + +func TestTextContent(t *testing.T) { + root, err := ParseHTML(`

    Hello world

    `) + if err != nil { + t.Fatal(err) + } + p := FindFirst(root, scraper.Selector{Tag: "p"}) + if p == nil { + t.Fatal("expected to find

    ") + } + if got := TextContent(p); got != "Hello world" { + t.Errorf("TextContent = %q, want %q", got, "Hello world") + } +} + +// ── FindFirst / FindAll ─────────────────────────────────────────────────────── + +func TestFindFirst_ByTag(t *testing.T) { + root, _ := ParseHTML(`

    Title

    Sub

    `) + n := FindFirst(root, scraper.Selector{Tag: "h1"}) + if n == nil { + t.Fatal("expected to find

    ") + } + if TextContent(n) != "Title" { + t.Errorf("h1 text = %q, want %q", TextContent(n), "Title") + } +} + +func TestFindFirst_ByClass(t *testing.T) { + root, _ := ParseHTML(`JR`) + n := FindFirst(root, scraper.Selector{Tag: "span", Class: "author"}) + if n == nil { + t.Fatal("expected to find span.author") + } + if TextContent(n) != "JR" { + t.Errorf("author text = %q, want %q", TextContent(n), "JR") + } +} + +func TestFindFirst_ByID(t *testing.T) { + root, _ := ParseHTML(`

    text

    `) + n := FindFirst(root, scraper.Selector{ID: "content"}) + if n == nil { + t.Fatal("expected to find #content") + } +} + +func TestFindFirst_NoMatch(t *testing.T) { + root, _ := ParseHTML(`

    nothing

    `) + n := FindFirst(root, scraper.Selector{Tag: "h1"}) + if n != nil { + t.Errorf("expected nil for missing tag, got %v", n) + } +} + +func TestFindAll_Multiple(t *testing.T) { + root, _ := ParseHTML(` +
  • A
  • +
  • B
  • +
  • C
  • + `) + nodes := FindAll(root, scraper.Selector{Tag: "li", Class: "novel-item"}) + if len(nodes) != 2 { + t.Errorf("FindAll novel-item = %d, want 2", len(nodes)) + } +} + +// ── ExtractFirst / ExtractAll ───────────────────────────────────────────────── + +func TestExtractFirst_TextNode(t *testing.T) { + root, _ := ParseHTML(`

    Shadow Slave

    `) + got := ExtractFirst(root, scraper.Selector{Tag: "h1", Class: "novel-title"}) + if got != "Shadow Slave" { + t.Errorf("ExtractFirst title = %q, want %q", got, "Shadow Slave") + } +} + +func TestExtractFirst_AttrNode(t *testing.T) { + root, _ := ParseHTML(``) + got := ExtractFirst(root, scraper.Selector{Tag: "img", Attr: "src"}) + if got != "/covers/slug.jpg" { + t.Errorf("ExtractFirst img src = %q, want %q", got, "/covers/slug.jpg") + } +} + +func TestExtractFirst_Missing(t *testing.T) { + root, _ := ParseHTML(``) + got := ExtractFirst(root, scraper.Selector{Tag: "h1"}) + if got != "" { + t.Errorf("ExtractFirst missing = %q, want empty", got) + } +} + +func TestExtractAll_Genres(t *testing.T) { + root, _ := ParseHTML(` +
    + `) + genresNode := FindFirst(root, scraper.Selector{Tag: "div", Class: "genres"}) + if genresNode == nil { + t.Fatal("expected genres div") + } + genres := ExtractAll(genresNode, scraper.Selector{Tag: "a"}) + if len(genres) != 2 { + t.Fatalf("genres = %v, want 2", genres) + } + if genres[0] != "Action" || genres[1] != "Fantasy" { + t.Errorf("genres = %v, want [Action Fantasy]", genres) + } +} + +// ── NodeToMarkdown ──────────────────────────────────────────────────────────── + +func TestNodeToMarkdown_Paragraphs(t *testing.T) { + root, _ := ParseHTML(`
    +

    First paragraph.

    +

    Second paragraph.

    +
    `) + container := FindFirst(root, scraper.Selector{ID: "content"}) + if container == nil { + t.Fatal("missing #content") + } + md := NodeToMarkdown(container) + if md == "" { + t.Fatal("NodeToMarkdown returned empty string") + } + for _, want := range []string{"First paragraph", "Second paragraph"} { + if !strings.Contains(md, want) { + t.Errorf("NodeToMarkdown missing %q in:\n%s", want, md) + } + } +} + +func TestNodeToMarkdown_Bold(t *testing.T) { + root, _ := ParseHTML(`

    He was very strong.

    `) + container := FindFirst(root, scraper.Selector{ID: "content"}) + md := NodeToMarkdown(container) + if !strings.Contains(md, "**very**") { + t.Errorf("NodeToMarkdown should wrap in **, got:\n%s", md) + } +} + +func TestNodeToMarkdown_ScriptStripped(t *testing.T) { + root, _ := ParseHTML(`

    Good

    `) + container := FindFirst(root, scraper.Selector{ID: "content"}) + md := NodeToMarkdown(container) + if strings.Contains(md, "alert") { + t.Errorf("NodeToMarkdown should strip - - - -` - -const layoutFoot = `` - -func renderPage(w http.ResponseWriter, title, body string) { - t := template.Must(template.New("layout").Parse(layoutHead + body + layoutFoot)) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = t.Execute(w, struct{ Title string }{Title: title}) -} - -func renderFragment(w http.ResponseWriter, body string) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, body) -} - -func isHTMX(r *http.Request) bool { - return r.Header.Get("HX-Request") == "true" -} - -// respond writes either a full page or an HTMX fragment depending on the request. -func (s *Server) respond(w http.ResponseWriter, r *http.Request, title, fragment string) { - if isHTMX(r) { - renderFragment(w, fragment) - return - } - renderPage(w, title, - `
    `+fragment+`
    `) -} - -// ─── GET / — book catalogue ─────────────────────────────────────────────────── - -const homeTmpl = ` - - -
    - - -
    -

    libnovel

    - -
    - - - -

    {{len .Books}} book{{if ne (len .Books) 1}}s{{end}} on disk

    - - - - - - - - - - - -
    -
    - -
    - -
    - - - - -
    - - - -
    - - - -
    - -` - -// homeBookItem wraps BookMeta with the count of chapters already on disk -// and the Unix timestamp of when the book was added (metadata.yaml mtime). -type homeBookItem struct { - scraper.BookMeta - Downloaded int - AddedAt int64 -} - -func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - - books, err := s.writer.ListBooks() - if err != nil { - http.Error(w, "failed to list books: "+err.Error(), http.StatusInternalServerError) - return - } - - items := make([]homeBookItem, len(books)) - for i, b := range books { - items[i] = homeBookItem{ - BookMeta: b, - Downloaded: s.writer.CountChapters(b.Slug), - AddedAt: s.writer.MetadataMtime(b.Slug), - } - } - - t := template.Must(template.New("home").Parse(homeTmpl)) - var buf bytes.Buffer - _ = t.Execute(&buf, struct { - Books interface{} - }{ - Books: items, - }) - - s.respond(w, r, "Home", buf.String()) -} - -// ─── GET /scrape — add a new book ───────────────────────────────────────────── - -const scrapeTmpl = ` -
    - - ← All books - - -

    Add a book

    -

    Search the rankings or paste a novelfire.net URL to scrape a new book.

    - -
    -
    - - - - - -
    - -
    -
    -
    - -` - -func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) { - rankingItems, _ := s.writer.ReadRankingItems() - rankingJSON, _ := json.Marshal(rankingItems) - - t := template.Must(template.New("scrape").Parse(scrapeTmpl)) - var buf bytes.Buffer - _ = t.Execute(&buf, struct { - RankingJSON template.JS - }{ - RankingJSON: template.JS(rankingJSON), - }) - - s.respond(w, r, "Add a book", buf.String()) -} - -// ─── GET /ranking — ranking page ─────────────────────────────────────────────── - -const rankingTmpl = ` - - -
    - - - - - -
    - - ← All books - -
    -
    -

    Novel Rankings

    -
    - {{if .TotalItems}}{{.TotalItems}} novels{{end}} - {{if .CachedAt}}· cached {{.CachedAt}}{{end}} - -
    -
    -
    - - -
    -
    -
    -
    - - -
    - -
    - - - - -
    - - -
    - Filter: - - {{range .AllStatuses}} - - {{end}} - {{range .TopGenres}} - - {{end}} - -
    -
    - - -
    - {{range .Books}} - {{if .Local}} - - -
    - {{if .Rank}}#{{.Rank}}{{end}} -
    - -
    - {{if .Cover}} - cover - {{else}} -
    N/A
    - {{end}} -
    - -
    -
    -

    {{.Title}}

    - In library -
    - {{if .Author}}

    {{.Author}}

    {{end}} -
    - {{if .Status}}{{.Status}}{{end}} - {{range .Genres}}{{.}}{{end}} -
    -
    -
    - {{else}} -
    - -
    - {{if .Rank}}#{{.Rank}}{{end}} -
    - -
    - {{if .Cover}} - cover - {{else}} -
    N/A
    - {{end}} -
    - -
    -

    {{.Title}}

    - {{if .Author}}

    {{.Author}}

    {{end}} -
    - {{if .Status}}{{.Status}}{{end}} - {{range .Genres}}{{.}}{{end}} -
    - {{if .SourceURL}} -
    - - -
    - {{end}} -
    -
    - {{end}} - {{else}} -
    -

    No ranking data cached yet.

    -
    - - -
    -
    - {{end}} -
    - - - -
    - -` - -// rankingViewItem enriches a RankingItem with whether it is present in the -// local book library, so the template can highlight it differently. -type rankingViewItem struct { - writer.RankingItem - Local bool -} - -// toRankingViewItems annotates items with Local=true for slugs found in localSlugs. -func toRankingViewItems(items []writer.RankingItem, localSlugs map[string]bool) []rankingViewItem { - out := make([]rankingViewItem, len(items)) - for i, it := range items { - out[i] = rankingViewItem{ - RankingItem: it, - Local: localSlugs[it.Slug], - } - } - return out -} - -// pageNum is one entry in the ranking pagination bar. -// Num == 0 is a sentinel that renders as an ellipsis gap. -type pageNum struct { - Num int -} - -// rankingPageNums builds a pagination list with smart ellipsis. -// It always shows: first 2, last 2, and a ±2 window around current. -// Gaps between non-consecutive runs are filled with a sentinel (Num==0) for "…". -// Pass current=0 when there is no concept of a current page (e.g. fetch bar). -func rankingPageNums(total, current int) []pageNum { - if total <= 0 { - return nil - } - show := make(map[int]bool) - // First 2 and last 2. - for i := 1; i <= 2 && i <= total; i++ { - show[i] = true - } - for i := total - 1; i <= total; i++ { - if i >= 1 { - show[i] = true - } - } - // ±2 window around current page. - if current > 0 { - for i := current - 2; i <= current+2; i++ { - if i >= 1 && i <= total { - show[i] = true - } - } - } - - // Collect and sort. - pages := make([]int, 0, len(show)) - for p := range show { - pages = append(pages, p) - } - for i := 0; i < len(pages); i++ { - for j := i + 1; j < len(pages); j++ { - if pages[j] < pages[i] { - pages[i], pages[j] = pages[j], pages[i] - } - } - } - - // Build output with ellipsis sentinels between non-consecutive pages. - out := make([]pageNum, 0, len(pages)*2) - for i, p := range pages { - if i > 0 && p > pages[i-1]+1 { - out = append(out, pageNum{0}) - } - out = append(out, pageNum{p}) - } - return out -} - -const rankingPageSize = 20 - -// handleRanking serves the ranking page from the cached ranking.json file. -// It does NOT trigger a live scrape; use POST /ranking/refresh for that. -// Supports ?page=N for browsing through cached items (20 per page). -func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) { - rankingItems, err := s.writer.ReadRankingItems() - if err != nil { - s.log.Error("failed to read cached ranking", "err", err) - } - - cachedAt := "" - if info, statErr := s.writer.RankingFileInfo(); statErr == nil { - cachedAt = info.ModTime().Format("Jan 2, 2006 at 15:04") - } - - // Parse requested display page (1-indexed). - currentPage := 1 - if p := r.URL.Query().Get("page"); p != "" { - if n, err2 := strconv.Atoi(p); err2 == nil && n > 0 { - currentPage = n - } - } - - totalItems := len(rankingItems) - totalPages := 1 - if totalItems > 0 { - totalPages = (totalItems + rankingPageSize - 1) / rankingPageSize - } - if currentPage > totalPages { - currentPage = totalPages - } - - // Slice items for the current display page. - start := (currentPage - 1) * rankingPageSize - end := start + rankingPageSize - if end > totalItems { - end = totalItems - } - pageItems := rankingItems - if totalItems > 0 { - pageItems = rankingItems[start:end] - } - - t := template.Must(template.New("ranking").Parse(rankingTmpl)) - var buf bytes.Buffer - - // Collect distinct genres and statuses across ALL items for facet filters. - genreFreq := map[string]int{} - statusSet := map[string]bool{} - for _, it := range rankingItems { - if it.Status != "" { - statusSet[it.Status] = true - } - for _, g := range it.Genres { - genreFreq[g]++ - } - } - allStatuses := sortedKeys(statusSet) - - // Build top-10 genres sorted by frequency descending. - type genreCount struct { - name string - count int - } - gcSlice := make([]genreCount, 0, len(genreFreq)) - for g, c := range genreFreq { - gcSlice = append(gcSlice, genreCount{g, c}) - } - sort.Slice(gcSlice, func(i, j int) bool { - if gcSlice[i].count != gcSlice[j].count { - return gcSlice[i].count > gcSlice[j].count - } - return gcSlice[i].name < gcSlice[j].name - }) - topN := 10 - if len(gcSlice) < topN { - topN = len(gcSlice) - } - topGenres := make([]string, topN) - for i := 0; i < topN; i++ { - topGenres[i] = gcSlice[i].name - } - - // Encode full dataset + local slugs for client-side cross-page filtering. - localSlugs := s.writer.LocalSlugs() - type rankingJSONItem struct { - Rank int `json:"rank"` - Slug string `json:"slug"` - Title string `json:"title"` - Author string `json:"author,omitempty"` - Cover string `json:"cover,omitempty"` - Status string `json:"status,omitempty"` - Genres []string `json:"genres,omitempty"` - SourceURL string `json:"source_url,omitempty"` - Local bool `json:"local"` - } - allItemsForJS := make([]rankingJSONItem, len(rankingItems)) - for i, it := range rankingItems { - allItemsForJS[i] = rankingJSONItem{ - Rank: it.Rank, - Slug: it.Slug, - Title: it.Title, - Author: it.Author, - Cover: it.Cover, - Status: it.Status, - Genres: it.Genres, - SourceURL: it.SourceURL, - Local: localSlugs[it.Slug], - } - } - allItemsJSON, _ := json.Marshal(allItemsForJS) - - _ = t.Execute(&buf, struct { - Books interface{} - CachedAt string - FetchNums []pageNum - DisplayNums []pageNum - CurrentPage int - TotalPages int - TotalItems int - TopGenres []string - AllStatuses []string - AllItemsJSON template.JS - }{ - Books: toRankingViewItems(pageItems, localSlugs), - CachedAt: cachedAt, - FetchNums: rankingPageNums(100, 0), - DisplayNums: rankingPageNums(totalPages, currentPage), - CurrentPage: currentPage, - TotalPages: totalPages, - TotalItems: totalItems, - TopGenres: topGenres, - AllStatuses: allStatuses, - AllItemsJSON: template.JS(allItemsJSON), - }) - s.respond(w, r, "Rankings", buf.String()) -} - -// handleRankingRefresh starts an async scrape of novelfire.net/ranking and -// immediately returns a polling badge. The browser polls /ui/ranking/status -// until the job finishes, then follows an HX-Redirect back to /ranking. -// -// Accepts an optional form field "pages" (integer ≥ 1). 0 or absent means -// fetch all pages; otherwise at most that many pages are scraped. -func (s *Server) handleRankingRefresh(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - maxPages := 0 - if p := strings.TrimSpace(r.FormValue("pages")); p != "" { - if n, err := strconv.Atoi(p); err == nil && n > 0 { - maxPages = n - } - } - - s.mu.Lock() - if s.rankingRunning { - s.mu.Unlock() - renderFragment(w, rankingStatusHTML("running", "Ranking refresh already in progress…")) - return - } - s.rankingRunning = true - s.mu.Unlock() - - go func() { - defer func() { - s.mu.Lock() - s.rankingRunning = false - s.mu.Unlock() - }() - - // Allow ~90 s per page; minimum 120 s for a single page. - timeout := 120 * time.Second - if maxPages > 1 { - timeout = time.Duration(maxPages) * 90 * time.Second - } - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - rankingCh, errCh := s.novel.ScrapeRanking(ctx, maxPages) - - var rankingItems []writer.RankingItem - for rankingCh != nil || errCh != nil { - select { - case meta, ok := <-rankingCh: - if !ok { - rankingCh = nil - } else { - rankingItems = append(rankingItems, writer.RankingItem{ - Rank: meta.Ranking, - Slug: meta.Slug, - Title: meta.Title, - Author: meta.Author, - Cover: meta.Cover, - Status: meta.Status, - Genres: meta.Genres, - SourceURL: meta.SourceURL, - }) - } - case err, ok := <-errCh: - if !ok { - errCh = nil - } else if err != nil { - s.log.Error("ranking scrape error", "err", err) - } - } - } - - if len(rankingItems) > 0 { - if err := s.writer.WriteRanking(rankingItems); err != nil { - s.log.Error("failed to save ranking", "err", err) - } - } - }() - - renderFragment(w, rankingStatusHTML("running", "Fetching rankings…")) -} - -// handleRankingStatus is the HTMX polling endpoint for ranking refresh jobs. -// While running it returns a self-replacing badge; when done it issues an -// HX-Redirect so the browser navigates to /ranking. -func (s *Server) handleRankingStatus(w http.ResponseWriter, r *http.Request) { - s.mu.Lock() - running := s.rankingRunning - s.mu.Unlock() - - if running { - renderFragment(w, rankingStatusHTML("running", "Fetching rankings…")) - return - } - // Job done — redirect the HTMX request to the ranking page. - w.Header().Set("HX-Redirect", "/ranking") - w.WriteHeader(http.StatusOK) -} - -// rankingStatusHTML returns a self-replacing polling badge for the ranking -// refresh job. state is "running" or "done". -func rankingStatusHTML(state, msg string) string { - var colour, dot, poll string - switch state { - case "running": - colour = "text-amber-300 bg-amber-950 border-amber-800" - dot = `` - poll = `hx-get="/ui/ranking/status" hx-trigger="every 3s" hx-target="this" hx-swap="outerHTML"` - default: - colour = "text-green-300 bg-green-950 border-green-800" - dot = `` - } - return fmt.Sprintf( - `
    %s%s
    `, - colour, poll, dot, template.HTMLEscapeString(msg), - ) -} - -// ─── GET /ranking/view — view ranking markdown ───────────────────────────────── - -const rankingViewTmpl = ` -
    - - ← Back to Rankings - - -

    Ranking Data

    - -
    {{.JSON}}
    -
    ` - -func (s *Server) handleRankingView(w http.ResponseWriter, r *http.Request) { - items, err := s.writer.ReadRankingItems() - if err != nil { - http.Error(w, "failed to read ranking: "+err.Error(), http.StatusInternalServerError) - return - } - if len(items) == 0 { - http.NotFound(w, r) - return - } - - pretty, err := json.MarshalIndent(items, "", " ") - if err != nil { - http.Error(w, "json marshal error: "+err.Error(), http.StatusInternalServerError) - return - } - - t := template.Must(template.New("rankingView").Parse(rankingViewTmpl)) - var buf bytes.Buffer - _ = t.Execute(&buf, struct{ JSON string }{JSON: string(pretty)}) - - s.respond(w, r, "Ranking Data", buf.String()) -} - -// ─── GET /books/{slug} — chapter list ──────────────────────────────────────── - -const chapterPageSize = 50 - -const bookTmpl = ` -
    - - ← All books - - - - - - - - -
    - {{if .Meta.Cover}} - cover - {{end}} -
    -
    -

    {{.Meta.Title}}

    - {{if .Meta.SourceURL}} - - - - - Source - -
    - - -
    - {{end}} -
    - {{if .Meta.Author}}

    {{.Meta.Author}}

    {{end}} -
    - {{if .Meta.Status}}{{.Meta.Status}}{{end}} - {{if .Meta.TotalChapters}}{{.Meta.TotalChapters}} ch total{{end}} - {{.TotalDownloaded}} downloaded -
    - {{if .Meta.Summary}} -

    {{.Meta.Summary}}

    - {{end}} -
    -
    - - - - - - {{if .LastChapter}} - - {{end}} - -

    Chapters

    - - - {{if gt .TotalPages 1}} -
    - {{if gt .CurrentPage 1}} - « - {{end}} - {{range pages .TotalPages}} - {{if eq . $.CurrentPage}} - {{.}} - {{else}} - {{.}} - {{end}} - {{end}} - {{if lt .CurrentPage .TotalPages}} - » - {{end}} -
    - {{end}} -
    - -` - -func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) { - slug := r.PathValue("slug") - - meta, ok, err := s.writer.ReadMetadata(slug) - if err != nil { - http.Error(w, "failed to read metadata: "+err.Error(), http.StatusInternalServerError) - return - } - if !ok { - http.NotFound(w, r) - return - } - - chapters, err := s.writer.ListChapters(slug) - if err != nil { - http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError) - return - } - - total := len(chapters) - totalPages := (total + chapterPageSize - 1) / chapterPageSize - if totalPages < 1 { - totalPages = 1 - } - - currentPage := 1 - page := chapters - if total > chapterPageSize { - page = chapters[:chapterPageSize] - } - - funcMap := template.FuncMap{ - "pages": func(n int) []int { - out := make([]int, n) - for i := range out { - out[i] = i + 1 - } - return out - }, - "prev": func(n int) int { return n - 1 }, - "next": func(n int) int { return n + 1 }, - } - - t := template.Must(template.New("book").Funcs(funcMap).Parse(bookTmpl)) - var buf bytes.Buffer - lastChapter := 0 - if total > 0 { - lastChapter = chapters[total-1].Number - } - _ = t.Execute(&buf, struct { - Slug string - Meta interface{} - Chapters interface{} - TotalDownloaded int - TotalPages int - CurrentPage int - LastChapter int - }{ - Slug: slug, - Meta: meta, - Chapters: page, - TotalDownloaded: total, - TotalPages: totalPages, - CurrentPage: currentPage, - LastChapter: lastChapter, - }) - - s.respond(w, r, meta.Title, buf.String()) -} - -// ─── GET /books/{slug}/chapters-page — paginated chapter list fragment ──────── - -const chapterPageTmpl = `{{range .Chapters}} -
  • - - {{.Number}} -
    - {{.Title}} - {{if .Date}}{{.Date}}{{end}} -
    - -
    -
  • -{{end}} -
    - {{if gt .CurrentPage 1}} - « - {{end}} - {{range pages .TotalPages}} - {{if eq . $.CurrentPage}} - {{.}} - {{else}} - {{.}} - {{end}} - {{end}} - {{if lt .CurrentPage .TotalPages}} - » - {{end}} -
    ` - -func (s *Server) handleBookChaptersPage(w http.ResponseWriter, r *http.Request) { - slug := r.PathValue("slug") - currentPage := 1 - if p := r.URL.Query().Get("page"); p != "" { - if n, err := strconv.Atoi(p); err == nil && n > 0 { - currentPage = n - } - } - - chapters, err := s.writer.ListChapters(slug) - if err != nil { - http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError) - return - } - - total := len(chapters) - totalPages := (total + chapterPageSize - 1) / chapterPageSize - if totalPages < 1 { - totalPages = 1 - } - - start := (currentPage - 1) * chapterPageSize - if start >= total { - w.WriteHeader(http.StatusNoContent) - return - } - end := start + chapterPageSize - if end > total { - end = total - } - - funcMap := template.FuncMap{ - "pages": func(n int) []int { - out := make([]int, n) - for i := range out { - out[i] = i + 1 - } - return out - }, - "prev": func(n int) int { return n - 1 }, - "next": func(n int) int { return n + 1 }, - } - - t := template.Must(template.New("chapterPage").Funcs(funcMap).Parse(chapterPageTmpl)) - var buf bytes.Buffer - _ = t.Execute(&buf, struct { - Slug string - Chapters interface{} - TotalPages int - CurrentPage int - }{ - Slug: slug, - Chapters: chapters[start:end], - TotalPages: totalPages, - CurrentPage: currentPage, - }) - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = buf.WriteTo(w) -} - -// ─── GET /books/{slug}/chapters/{n} — chapter reader ───────────────────────── - -const chapterTmpl = ` - - - - - - -
    - - -
    -

    Chapter {{.ChapterN}}

    -

    {{.Title}}

    - {{if .ChapterDate}}

    {{.ChapterDate}}

    {{end}} -
    - -
    - {{.HTML}} -
    - -
    - - - - -` - -func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) { - slug := r.PathValue("slug") - n, err := strconv.Atoi(r.PathValue("n")) - if err != nil || n < 1 { - http.NotFound(w, r) - return - } - - raw, err := s.writer.ReadChapter(slug, n) - if err != nil { - http.NotFound(w, r) - return - } - - // Strip the first heading line so it isn't rendered as a duplicate

    - // inside the article (the template already renders an explicit

    ). - rawForHTML := stripFirstHeadingLine(raw) - - var htmlBuf bytes.Buffer - if err := md.Convert([]byte(rawForHTML), &htmlBuf); err != nil { - http.Error(w, "markdown render error: "+err.Error(), http.StatusInternalServerError) - return - } - - chapters, _ := s.writer.ListChapters(slug) - prevN, nextN := adjacentChapters(chapters, n) - - title := firstHeading(raw, fmt.Sprintf("Chapter %d", n)) - chapterTitle, chapterDate := writer.SplitChapterTitle(title) - - // Load cover URL for Media Session artwork (best-effort; ignore errors). - var coverURL string - if meta, ok, err := s.writer.ReadMetadata(slug); err == nil && ok { - coverURL = meta.Cover - } - - t := template.Must(template.New("chapter").Parse(chapterTmpl)) - var buf bytes.Buffer - _ = t.Execute(&buf, struct { - Slug string - HTML template.HTML - PrevN int - NextN int - ChapterN int - Title string - ChapterDate string - AllChapters interface{} - Voices []voiceInfo - DefaultVoice string - Cover string - }{ - Slug: slug, - HTML: template.HTML(htmlBuf.String()), - PrevN: prevN, - NextN: nextN, - ChapterN: n, - Title: chapterTitle, - ChapterDate: chapterDate, - AllChapters: chapters, - Voices: parseVoices(s.voices()), - DefaultVoice: s.kokoroVoice, - Cover: coverURL, - }) - - s.respond(w, r, chapterTitle, buf.String()) -} - -// ─── helpers ────────────────────────────────────────────────────────────────── - -// sortedKeys returns the keys of a string-bool map in sorted order. -func sortedKeys(m map[string]bool) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - // Simple insertion sort — sets are small (< 100 items). - for i := 1; i < len(out); i++ { - for j := i; j > 0 && out[j] < out[j-1]; j-- { - out[j], out[j-1] = out[j-1], out[j] - } - } - return out -} - -// stripMarkdown removes Markdown syntax and returns clean plain text. -func stripMarkdown(src string) string { - src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "") - src = regexp.MustCompile(`\*{1,3}|_{1,3}`).ReplaceAllString(src, "") - src = regexp.MustCompile("(?s)```.*?```").ReplaceAllString(src, "") - src = regexp.MustCompile("`[^`]*`").ReplaceAllString(src, "") - src = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(src, "$1") - src = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`).ReplaceAllString(src, "") - src = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(src, "") - src = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`).ReplaceAllString(src, "") - src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n") - return strings.TrimSpace(src) -} - -// adjacentChapters returns the chapter numbers immediately before and after n -// in the sorted chapters list. 0 means "does not exist". -func adjacentChapters(chapters []writer.ChapterInfo, n int) (prev, next int) { - for i, ch := range chapters { - if ch.Number == n { - if i > 0 { - prev = chapters[i-1].Number - } - if i < len(chapters)-1 { - next = chapters[i+1].Number - } - return - } - } - return -} - -// stripFirstHeadingLine removes the first non-empty line if it is a markdown -// heading (starts with one or more "#"). This prevents the heading from being -// rendered as a duplicate

    inside the article when the template already -// renders an explicit title above the article. -func stripFirstHeadingLine(src string) string { - lines := strings.SplitN(src, "\n", -1) - for i, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - continue - } - if strings.HasPrefix(trimmed, "#") { - // Remove this line and return the rest. - rest := strings.Join(append(lines[:i], lines[i+1:]...), "\n") - return strings.TrimLeft(rest, "\n") - } - // First non-empty line is not a heading — nothing to strip. - break - } - return src -} - -// firstHeading returns the text of the first non-empty line, stripping a -// leading "# " markdown heading marker. Falls back to fallback. -func firstHeading(md, fallback string) string { - for _, line := range strings.SplitN(md, "\n", 20) { - line = strings.TrimSpace(line) - if line == "" { - continue - } - return strings.TrimPrefix(line, "# ") - } - return fallback -} - -// ─── POST /ui/scrape/book — form submission ─────────────────────────────────── - -func (s *Server) handleUIScrapeBook(w http.ResponseWriter, r *http.Request) { - bookURL := strings.TrimSpace(r.FormValue("url")) - if bookURL == "" { - renderFragment(w, scrapeStatusHTML("error", "Please enter a book URL.")) - return - } - - s.mu.Lock() - already := s.running - if !already { - s.running = true - } - s.mu.Unlock() - - if already { - renderFragment(w, scrapeStatusHTML("busy", "A scrape job is already running. Please wait.")) - return - } - - cfg := s.oCfg - cfg.SingleBookURL = bookURL - - go func() { - defer func() { - s.mu.Lock() - s.running = false - s.mu.Unlock() - }() - - ctx, cancel := context.WithTimeout(context.Background(), 24*time.Hour) - defer cancel() - - o := orchestrator.New(cfg, s.novel, s.log) - if err := o.Run(ctx); err != nil { - s.log.Error("UI scrape job failed", "url", bookURL, "err", err) - } - }() - - // Return a status badge that polls until the job finishes. - renderFragment(w, scrapeStatusHTML("running", "Scraping "+bookURL+"…")) -} - -// ─── GET /ui/scrape/status — polling endpoint ───────────────────────────────── - -func (s *Server) handleUIScrapeStatus(w http.ResponseWriter, r *http.Request) { - s.mu.Lock() - running := s.running - s.mu.Unlock() - - if running { - // Keep polling every 3 s while the job is in progress. - renderFragment(w, scrapeStatusHTML("running", "Scraping in progress…")) - return - } - // Job finished — show a done badge and stop polling. - renderFragment(w, scrapeStatusHTML("done", "Done! Refresh the page to see new books.")) -} - -// scrapeStatusHTML returns a self-contained status badge fragment. -// state is one of: "running" | "done" | "busy" | "error". -func scrapeStatusHTML(state, msg string) string { - var colour, dot, poll string - switch state { - case "running": - colour = "text-amber-300 bg-amber-950 border-amber-800" - dot = `` - poll = `hx-get="/ui/scrape/status" hx-trigger="every 3s" hx-target="this" hx-swap="outerHTML"` - case "done": - colour = "text-green-300 bg-green-950 border-green-800" - dot = `` - case "busy": - colour = "text-yellow-300 bg-yellow-950 border-yellow-800" - dot = `` - default: // error - colour = "text-red-300 bg-red-950 border-red-800" - dot = `` - } - return fmt.Sprintf( - `
    %s%s
    `, - colour, poll, dot, template.HTMLEscapeString(msg), - ) -} diff --git a/scraper/internal/storage/coverutil.go b/scraper/internal/storage/coverutil.go new file mode 100644 index 0000000..787c029 --- /dev/null +++ b/scraper/internal/storage/coverutil.go @@ -0,0 +1,59 @@ +package storage + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "time" +) + +// DownloadAndStoreCover fetches the image at imageURL and stores it in the +// store under key. Errors are logged but not returned — this is best-effort. +// If the asset is already present the download is skipped. +func DownloadAndStoreCover(store Store, log *slog.Logger, key, imageURL string) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Skip if already stored. + if _, _, ok, _ := store.GetBrowseAsset(ctx, key); ok { + return + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + if err != nil { + log.Warn("cover: build request failed", "key", key, "url", imageURL, "err", err) + return + } + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-scraper/1.0)") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Warn("cover: fetch failed", "key", key, "url", imageURL, "err", fmt.Errorf("%w", err)) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Warn("cover: non-200 response", "key", key, "url", imageURL, "status", resp.StatusCode) + return + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + log.Warn("cover: read body failed", "key", key, "url", imageURL, "err", err) + return + } + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "image/jpeg" + } + + if err := store.SaveBrowseAsset(ctx, key, data, contentType); err != nil { + log.Warn("cover: SaveBrowseAsset failed", "key", key, "err", err) + return + } + log.Debug("cover: stored", "key", key, "bytes", len(data)) +} diff --git a/scraper/internal/storage/hybrid.go b/scraper/internal/storage/hybrid.go new file mode 100644 index 0000000..68cee40 --- /dev/null +++ b/scraper/internal/storage/hybrid.go @@ -0,0 +1,560 @@ +// hybrid.go implements the Store interface using PocketBase for structured data +// and MinIO for binary chapter/audio blobs. +package storage + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sort" + "strconv" + "strings" + "time" + + "github.com/libnovel/scraper/internal/scraper" +) + +// HybridStore satisfies Store by routing structured data to PocketBase and +// binary objects (chapters, audio) to MinIO. +type HybridStore struct { + pb *PocketBaseStore + minio *MinioClient + log *slog.Logger +} + +// NewHybridStore constructs a HybridStore. It connects to both backends and +// calls EnsureCollections to bootstrap any missing PocketBase collections. +func NewHybridStore(ctx context.Context, pbCfg PocketBaseConfig, minioCfg MinioConfig, log *slog.Logger) (*HybridStore, error) { + mc, err := NewMinioClient(ctx, minioCfg) + if err != nil { + return nil, fmt.Errorf("storage: minio: %w", err) + } + pb := NewPocketBaseStore(pbCfg, log) + // Verify PocketBase credentials before proceeding. + if err := pb.Ping(ctx); err != nil { + return nil, fmt.Errorf("storage: pocketbase auth: %w", err) + } + if err := pb.EnsureCollections(ctx); err != nil { + // Non-fatal: 400/422 means collections already exist. + log.Warn("EnsureCollections returned an error (may be safe to ignore)", "err", err) + } + if err := pb.EnsureMigrations(ctx); err != nil { + log.Warn("EnsureMigrations returned an error", "err", err) + } + return &HybridStore{pb: pb, minio: mc, log: log}, nil +} + +// ─── Book metadata ──────────────────────────────────────────────────────────── + +func (h *HybridStore) WriteMetadata(ctx context.Context, meta scraper.BookMeta) error { + return h.pb.UpsertBook(ctx, + meta.Slug, meta.Title, meta.Author, meta.Cover, + meta.Status, meta.Summary, meta.SourceURL, + meta.Genres, meta.TotalChapters, meta.Ranking, + ) +} + +func (h *HybridStore) ReadMetadata(ctx context.Context, slug string) (scraper.BookMeta, bool, error) { + rec, found, err := h.pb.GetBook(ctx, slug) + if err != nil || !found { + return scraper.BookMeta{}, found, err + } + return recToBookMeta(rec), true, nil +} + +func (h *HybridStore) ListBooks(ctx context.Context) ([]scraper.BookMeta, error) { + rows, err := h.pb.ListBooks(ctx) + if err != nil { + return nil, err + } + books := make([]scraper.BookMeta, 0, len(rows)) + for _, r := range rows { + books = append(books, recToBookMeta(r)) + } + return books, nil +} + +func (h *HybridStore) LocalSlugs(ctx context.Context) (map[string]bool, error) { + books, err := h.ListBooks(ctx) + if err != nil { + return nil, err + } + slugs := make(map[string]bool, len(books)) + for _, b := range books { + slugs[b.Slug] = true + } + return slugs, nil +} + +func (h *HybridStore) MetadataMtime(ctx context.Context, slug string) int64 { + t, err := h.pb.BookMetaUpdated(ctx, slug) + if err != nil { + h.log.Warn("MetadataMtime: BookMetaUpdated failed", "slug", slug, "err", err) + return 0 + } + if t.IsZero() { + return 0 + } + return t.Unix() +} + +// ─── Chapters ───────────────────────────────────────────────────────────────── + +func (h *HybridStore) ChapterExists(ctx context.Context, slug string, ref scraper.ChapterRef) bool { + return h.minio.ChapterExists(ctx, slug, ref.Volume, ref.Number) +} + +func (h *HybridStore) WriteChapter(ctx context.Context, slug string, chapter scraper.Chapter) error { + content := "# " + chapter.Ref.Title + "\n\n" + chapter.Text + "\n" + if err := h.minio.PutChapter(ctx, slug, chapter.Ref.Volume, chapter.Ref.Number, content); err != nil { + return err + } + // Update chapter index in PocketBase. + title, dateLabel := splitChapterTitle(chapter.Ref.Title) + if err := h.pb.UpsertChapterIdx(ctx, slug, chapter.Ref.Number, title, dateLabel); err != nil { + h.log.Warn("WriteChapter: failed to upsert chapter index in PocketBase", + "slug", slug, "chapter", chapter.Ref.Number, "err", err) + } + return nil +} + +// WriteChapterRefs upserts chapter index rows (number + title) for all refs +// without writing any chapter text to MinIO. This pre-populates the chapter +// list when a book is first seen via a live preview. +func (h *HybridStore) WriteChapterRefs(ctx context.Context, slug string, refs []scraper.ChapterRef) error { + return h.pb.WriteChapterRefs(ctx, slug, refs) +} + +func (h *HybridStore) ReadChapter(ctx context.Context, slug string, n int) (string, error) { + return h.minio.GetChapter(ctx, slug, 0, n) +} + +func (h *HybridStore) ListChapters(ctx context.Context, slug string) ([]ChapterInfo, error) { + rows, err := h.pb.ListChapterIdx(ctx, slug) + if err != nil { + return nil, err + } + infos := make([]ChapterInfo, 0, len(rows)) + for _, r := range rows { + n := int(floatVal(r, "number")) + title, _ := r["title"].(string) + date, _ := r["date_label"].(string) + infos = append(infos, ChapterInfo{Number: n, Title: title, Date: date}) + } + sort.Slice(infos, func(i, j int) bool { return infos[i].Number < infos[j].Number }) + return infos, nil +} + +func (h *HybridStore) CountChapters(ctx context.Context, slug string) int { + return h.pb.CountChapterIdx(ctx, slug) +} + +// ReindexChapters walks all MinIO objects for slug, reads the title from the +// first line of each chapter markdown, and upserts them into chapters_idx. +// This repairs the PocketBase index when it falls out of sync with MinIO. +// Returns the number of chapters indexed and any non-fatal errors encountered. +func (h *HybridStore) ReindexChapters(ctx context.Context, slug string) (int, error) { + keys, err := h.minio.ListChapterKeys(ctx, slug) + if err != nil { + return 0, fmt.Errorf("reindex: list chapter keys: %w", err) + } + + count := 0 + var errs []string + for _, key := range keys { + // Parse chapter number from key: {slug}/vol-N/lo-hi/chapter-N.md + n := chapterNumberFromKey(key) + if n <= 0 { + h.log.Warn("ReindexChapters: could not parse chapter number from key", "key", key) + continue + } + + raw, readErr := h.minio.GetChapter(ctx, slug, 0, n) + if readErr != nil { + errs = append(errs, fmt.Sprintf("ch%d: %v", n, readErr)) + continue + } + + // Extract title from first line ("# Title text") or fall back to empty. + rawTitle := "" + if line, _, found := strings.Cut(raw, "\n"); found || raw != "" { + rawTitle = strings.TrimPrefix(strings.TrimSpace(line), "# ") + } + title, dateLabel := splitChapterTitle(rawTitle) + + if upsertErr := h.pb.UpsertChapterIdx(ctx, slug, n, title, dateLabel); upsertErr != nil { + errs = append(errs, fmt.Sprintf("ch%d upsert: %v", n, upsertErr)) + continue + } + count++ + } + + if len(errs) > 0 { + return count, fmt.Errorf("reindex: %d error(s): %s", len(errs), strings.Join(errs, "; ")) + } + return count, nil +} + +// chapterNumberFromKey parses the chapter number from a MinIO object key of the +// form "{slug}/vol-N/lo-hi/chapter-N.md". +func chapterNumberFromKey(key string) int { + // Grab the filename portion after the last '/'. + parts := strings.Split(key, "/") + if len(parts) == 0 { + return 0 + } + filename := parts[len(parts)-1] + // filename is "chapter-N.md" + filename = strings.TrimSuffix(filename, ".md") + filename = strings.TrimPrefix(filename, "chapter-") + n, err := strconv.Atoi(filename) + if err != nil || n <= 0 { + return 0 + } + return n +} + +// ─── Ranking ───────────────────────────────────────────────────────────────── + +func (h *HybridStore) WriteRankingItem(ctx context.Context, item RankingItem) error { + return h.pb.UpsertRankingItem(ctx, item) +} + +func (h *HybridStore) ReadRankingItems(ctx context.Context) ([]RankingItem, error) { + return h.pb.ListRankingItems(ctx) +} + +func (h *HybridStore) RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error) { + last, err := h.pb.RankingLastUpdated(ctx) + if err != nil { + return false, err + } + if last.IsZero() { + return false, nil + } + return time.Since(last) < maxAge, nil +} + +// ─── Audio cache ────────────────────────────────────────────────────────────── + +func (h *HybridStore) GetAudioCache(ctx context.Context, cacheKey string) (string, bool) { + filename, ok, err := h.pb.GetAudioCache(ctx, cacheKey) + if err != nil { + h.log.Warn("GetAudioCache: PocketBase lookup failed", "cache_key", cacheKey, "err", err) + } + return filename, ok +} + +func (h *HybridStore) SetAudioCache(ctx context.Context, cacheKey, filename string) error { + return h.pb.SetAudioCache(ctx, cacheKey, filename) +} + +// ─── Reading progress ───────────────────────────────────────────────────────── + +func (h *HybridStore) GetProgress(ctx context.Context, sessionID, slug string) (ReadingProgress, bool) { + ch, updated, ok, err := h.pb.GetProgress(ctx, sessionID, slug) + if err != nil { + h.log.Warn("GetProgress: PocketBase lookup failed", "slug", slug, "err", err) + return ReadingProgress{}, false + } + if !ok { + return ReadingProgress{}, false + } + return ReadingProgress{Slug: slug, Chapter: ch, UpdatedAt: updated}, true +} + +func (h *HybridStore) SetProgress(ctx context.Context, sessionID string, p ReadingProgress) error { + return h.pb.SetProgress(ctx, sessionID, p.Slug, p.Chapter) +} + +func (h *HybridStore) AllProgress(ctx context.Context, sessionID string) ([]ReadingProgress, error) { + rows, err := h.pb.AllProgress(ctx, sessionID) + if err != nil { + return nil, err + } + out := make([]ReadingProgress, 0, len(rows)) + for _, r := range rows { + slug, _ := r["slug"].(string) + ch := int(floatVal(r, "chapter")) + var updated time.Time + if ts, ok := r["updated"].(string); ok { + updated, _ = time.Parse(time.RFC3339, ts) + } + out = append(out, ReadingProgress{Slug: slug, Chapter: ch, UpdatedAt: updated}) + } + return out, nil +} + +func (h *HybridStore) DeleteProgress(ctx context.Context, sessionID, slug string) error { + return h.pb.DeleteProgress(ctx, sessionID, slug) +} + +// ─── AudioObjectKey ─────────────────────────────────────────────────────────── + +func (h *HybridStore) AudioObjectKey(slug string, n int, voice string) string { + return AudioObjectKey(slug, n, voice) +} + +func (h *HybridStore) AudioExists(ctx context.Context, key string) bool { + return h.minio.AudioExists(ctx, key) +} + +// ─── PutAudio ───────────────────────────────────────────────────────────────── + +func (h *HybridStore) PutAudio(ctx context.Context, key string, data []byte) error { + return h.minio.PutAudio(ctx, key, data) +} + +// ─── Presigned URLs ─────────────────────────────────────────────────────────── + +func (h *HybridStore) PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error) { + return h.minio.PresignChapter(ctx, slug, 0, n, expires) +} + +func (h *HybridStore) PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error) { + return h.minio.PresignAudio(ctx, key, expires) +} + +func (h *HybridStore) PresignAvatarUpload(ctx context.Context, userID, ext string) (string, string, error) { + return h.minio.PresignAvatarUploadURL(ctx, userID, ext) +} + +func (h *HybridStore) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) { + return h.minio.PresignAvatarURL(ctx, userID) +} + +func (h *HybridStore) DeleteAvatar(ctx context.Context, userID string) error { + return h.minio.DeleteAvatar(ctx, userID) +} + +// ─── Browse page snapshots ──────────────────────────────────────────────────── + +func (h *HybridStore) SaveBrowsePage(ctx context.Context, key, html string) error { + return h.minio.PutBrowsePage(ctx, key, html) +} + +func (h *HybridStore) GetBrowsePage(ctx context.Context, key string) (string, bool, error) { + return h.minio.GetBrowsePage(ctx, key) +} + +func (h *HybridStore) BrowseHTMLKey(domain string, page int) string { + return BrowseHTMLKey(domain, page) +} + +func (h *HybridStore) BrowseFilteredHTMLKey(domain string, page int, sort, genre, status string) string { + return BrowseFilteredHTMLKey(domain, page, sort, genre, status) +} + +func (h *HybridStore) BrowseCoverKey(domain, slug string) string { + return BrowseCoverKey(domain, slug) +} + +func (h *HybridStore) SaveBrowseAsset(ctx context.Context, key string, data []byte, contentType string) error { + return h.minio.PutBrowseAsset(ctx, key, data, contentType) +} + +func (h *HybridStore) GetBrowseAsset(ctx context.Context, key string) ([]byte, string, bool, error) { + return h.minio.GetBrowseAsset(ctx, key) +} + +// ─── Scraping tasks ─────────────────────────────────────────────────────────── + +func (h *HybridStore) CreateScrapeTask(ctx context.Context, kind, targetURL string) (string, error) { + return h.pb.CreateScrapingTask(ctx, kind, targetURL) +} + +func (h *HybridStore) UpdateScrapeTask(ctx context.Context, id string, u ScrapeTaskUpdate) error { + data := map[string]interface{}{ + "status": u.Status, + "books_found": u.BooksFound, + "chapters_scraped": u.ChaptersScraped, + "chapters_skipped": u.ChaptersSkipped, + "errors": u.Errors, + "error_message": u.ErrorMessage, + } + if !u.Finished.IsZero() { + data["finished"] = u.Finished.UTC().Format(time.RFC3339) + } + return h.pb.UpdateScrapingTask(ctx, id, data) +} + +func (h *HybridStore) ListScrapeTasks(ctx context.Context) ([]ScrapeTask, error) { + rows, err := h.pb.ListScrapingTasks(ctx) + if err != nil { + return nil, err + } + tasks := make([]ScrapeTask, 0, len(rows)) + for _, r := range rows { + t := ScrapeTask{ + ID: strVal(r, "id"), + Kind: strVal(r, "kind"), + TargetURL: strVal(r, "target_url"), + Status: strVal(r, "status"), + BooksFound: int(floatVal(r, "books_found")), + ChaptersScraped: int(floatVal(r, "chapters_scraped")), + ChaptersSkipped: int(floatVal(r, "chapters_skipped")), + Errors: int(floatVal(r, "errors")), + ErrorMessage: strVal(r, "error_message"), + } + if ts, ok := r["started"].(string); ok { + t.Started, _ = time.Parse(time.RFC3339, ts) + } + if ts, ok := r["finished"].(string); ok && ts != "" { + t.Finished, _ = time.Parse(time.RFC3339, ts) + } + tasks = append(tasks, t) + } + return tasks, nil +} + +// ─── Audio jobs ─────────────────────────────────────────────────────────────── + +func (h *HybridStore) CreateAudioJob(ctx context.Context, slug string, chapter int, voice string) (string, error) { + return h.pb.CreateAudioJob(ctx, slug, chapter, voice) +} + +func (h *HybridStore) UpdateAudioJob(ctx context.Context, id, status, errMsg string, finished time.Time) error { + return h.pb.UpdateAudioJob(ctx, id, status, errMsg, finished) +} + +func (h *HybridStore) GetAudioJob(ctx context.Context, cacheKey string) (AudioJob, bool, error) { + rec, ok, err := h.pb.GetAudioJob(ctx, cacheKey) + if err != nil || !ok { + return AudioJob{}, ok, err + } + job := AudioJob{ + ID: strVal(rec, "id"), + CacheKey: strVal(rec, "cache_key"), + Slug: strVal(rec, "slug"), + Chapter: int(floatVal(rec, "chapter")), + Voice: strVal(rec, "voice"), + Status: strVal(rec, "status"), + ErrorMessage: strVal(rec, "error_message"), + } + if ts, ok := rec["started"].(string); ok { + job.Started, _ = time.Parse(time.RFC3339, ts) + } + if ts, ok := rec["finished"].(string); ok && ts != "" { + job.Finished, _ = time.Parse(time.RFC3339, ts) + } + return job, true, nil +} + +func (h *HybridStore) ListAudioJobs(ctx context.Context) ([]AudioJob, error) { + rows, err := h.pb.ListAudioJobs(ctx) + if err != nil { + return nil, err + } + jobs := make([]AudioJob, 0, len(rows)) + for _, r := range rows { + job := AudioJob{ + ID: strVal(r, "id"), + CacheKey: strVal(r, "cache_key"), + Slug: strVal(r, "slug"), + Chapter: int(floatVal(r, "chapter")), + Voice: strVal(r, "voice"), + Status: strVal(r, "status"), + ErrorMessage: strVal(r, "error_message"), + } + if ts, ok := r["started"].(string); ok { + job.Started, _ = time.Parse(time.RFC3339, ts) + } + if ts, ok := r["finished"].(string); ok && ts != "" { + job.Finished, _ = time.Parse(time.RFC3339, ts) + } + jobs = append(jobs, job) + } + return jobs, nil +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +func recToBookMeta(rec map[string]interface{}) scraper.BookMeta { + m := scraper.BookMeta{ + Slug: strVal(rec, "slug"), + Title: strVal(rec, "title"), + Author: strVal(rec, "author"), + Cover: strVal(rec, "cover"), + Status: strVal(rec, "status"), + Summary: strVal(rec, "summary"), + SourceURL: strVal(rec, "source_url"), + } + if tc := floatVal(rec, "total_chapters"); tc > 0 { + m.TotalChapters = int(tc) + } + if rk := floatVal(rec, "ranking"); rk > 0 { + m.Ranking = int(rk) + } + // Genres stored as JSON string or array. + switch v := rec["genres"].(type) { + case string: + _ = json.Unmarshal([]byte(v), &m.Genres) + case []interface{}: + for _, g := range v { + if s, ok := g.(string); ok { + m.Genres = append(m.Genres, s) + } + } + } + return m +} + +func strVal(m map[string]interface{}, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +// splitChapterTitle mirrors writer.SplitChapterTitle logic (simplified). +func splitChapterTitle(raw string) (title, date string) { + raw = strings.TrimSpace(raw) + // Strip leading numeric index. + if idx := strings.IndexFunc(raw, func(r rune) bool { return r == ' ' || r == '\t' }); idx > 0 { + prefix := raw[:idx] + allDigit := true + for _, c := range prefix { + if c < '0' || c > '9' { + allDigit = false + break + } + } + if allDigit { + raw = strings.TrimSpace(raw[idx:]) + } + } + // Detect trailing relative date. Build a flat list of all suffixes once + // to avoid a double-nested loop. + units := []string{"second", "minute", "hour", "day", "week", "month", "year"} + suffixes := make([]string, 0, len(units)*2) + for _, u := range units { + suffixes = append(suffixes, u+"s ago", u+" ago") + } + lower := strings.ToLower(raw) + for _, suffix := range suffixes { + idx := strings.LastIndex(lower, suffix) + if idx <= 0 { + continue + } + // Find start of the numeric token that precedes the unit. + // Strip any whitespace that separates the number from the unit so + // that LastIndex finds the space before the digit, not the one + // between the digit and the unit word. + before := strings.TrimRight(raw[:idx], " \t") + start := strings.LastIndex(before, " ") + if start < 0 { + start = 0 + } else { + start++ // advance past the space to point at the digit + } + numPart := strings.TrimSpace(raw[start:idx]) + fields := strings.Fields(numPart) + if len(fields) > 0 { + if _, err := strconv.Atoi(fields[0]); err == nil { + return strings.TrimSpace(raw[:start]), strings.TrimSpace(raw[start : idx+len(suffix)]) + } + } + } + return raw, "" +} diff --git a/scraper/internal/storage/hybrid_integration_test.go b/scraper/internal/storage/hybrid_integration_test.go new file mode 100644 index 0000000..a278f2d --- /dev/null +++ b/scraper/internal/storage/hybrid_integration_test.go @@ -0,0 +1,473 @@ +//go:build integration + +// Integration tests for HybridStore (PocketBase + MinIO) end-to-end. +// +// Run with: +// +// MINIO_ENDPOINT=localhost:9000 \ +// POCKETBASE_URL=http://localhost:8090 \ +// go test -v -tags integration -timeout 120s \ +// github.com/libnovel/scraper/internal/storage +package storage + +import ( + "context" + "fmt" + "log/slog" + "strings" + "testing" + "time" + + "github.com/libnovel/scraper/internal/scraper" +) + +// newTestHybridStore constructs a HybridStore from environment variables. +// Skips the test if either MINIO_ENDPOINT or POCKETBASE_URL is unset. +func newTestHybridStore(t *testing.T) *HybridStore { + t.Helper() + if ep := envOr("MINIO_ENDPOINT", ""); ep == "" { + t.Skip("MINIO_ENDPOINT not set — skipping HybridStore integration test") + } + if u := envOr("POCKETBASE_URL", ""); u == "" { + t.Skip("POCKETBASE_URL not set — skipping HybridStore integration test") + } + + pbCfg := PocketBaseConfig{ + BaseURL: envOr("POCKETBASE_URL", "http://localhost:8090"), + AdminEmail: envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local"), + AdminPassword: envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123"), + } + minioCfg := MinioConfig{ + Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"), + AccessKey: envOr("MINIO_ACCESS_KEY", "admin"), + SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"), + UseSSL: envOr("MINIO_USE_SSL", "false") == "true", + BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"), + BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"), + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + hs, err := NewHybridStore(ctx, pbCfg, minioCfg, slog.Default()) + if err != nil { + t.Fatalf("NewHybridStore: %v", err) + } + return hs +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +// TestHybridStore_WriteReadMetadata exercises WriteMetadata → ReadMetadata round-trip. +func TestHybridStore_WriteReadMetadata(t *testing.T) { + hs := newTestHybridStore(t) + slug := testSlug(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + t.Cleanup(func() { + cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = hs.pb.pb.deleteWhere(cleanCtx, "books", fmt.Sprintf(`slug="%s"`, slug)) + }) + + meta := scraper.BookMeta{ + Slug: slug, + Title: "Hybrid Store Test Novel", + Author: "Test Author", + Cover: "https://example.com/cover.jpg", + Status: "Ongoing", + Genres: []string{"Fantasy", "Action"}, + Summary: "A novel for integration testing.", + TotalChapters: 99, + SourceURL: fmt.Sprintf("https://example.com/book/%s", slug), + Ranking: 5, + } + + t.Run("WriteMetadata", func(t *testing.T) { + if err := hs.WriteMetadata(ctx, meta); err != nil { + t.Fatalf("WriteMetadata: %v", err) + } + t.Logf("wrote metadata for slug=%q", slug) + }) + + t.Run("ReadMetadata", func(t *testing.T) { + got, found, err := hs.ReadMetadata(ctx, slug) + if err != nil { + t.Fatalf("ReadMetadata: %v", err) + } + if !found { + t.Fatal("ReadMetadata: not found after WriteMetadata") + } + t.Logf("read: %+v", got) + if got.Title != meta.Title { + t.Errorf("Title = %q, want %q", got.Title, meta.Title) + } + if got.Author != meta.Author { + t.Errorf("Author = %q, want %q", got.Author, meta.Author) + } + if got.TotalChapters != meta.TotalChapters { + t.Errorf("TotalChapters = %d, want %d", got.TotalChapters, meta.TotalChapters) + } + if got.Ranking != meta.Ranking { + t.Errorf("Ranking = %d, want %d", got.Ranking, meta.Ranking) + } + }) + + t.Run("MetadataMtime", func(t *testing.T) { + mtime := hs.MetadataMtime(ctx, slug) + if mtime == 0 { + t.Error("MetadataMtime returned 0") + } + t.Logf("mtime: %d (%s)", mtime, time.Unix(mtime, 0)) + }) + + t.Run("ReadMetadata_NotFound", func(t *testing.T) { + _, found, err := hs.ReadMetadata(ctx, "this-slug-does-not-exist-xyz") + if err != nil { + t.Fatalf("ReadMetadata (miss): %v", err) + } + if found { + t.Error("ReadMetadata returned found=true for a non-existent slug") + } + }) +} + +// TestHybridStore_WriteReadChapter exercises WriteChapter (MinIO blob + PocketBase +// index), ReadChapter, CountChapters, and ListChapters. +func TestHybridStore_WriteReadChapter(t *testing.T) { + hs := newTestHybridStore(t) + slug := testSlug(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + t.Cleanup(func() { + cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = hs.pb.pb.deleteWhere(cleanCtx, "chapters_idx", fmt.Sprintf(`slug="%s"`, slug)) + // MinIO objects are not cleaned up — they use the test slug as prefix + // and are effectively isolated. + }) + + chapters := []scraper.Chapter{ + { + Ref: scraper.ChapterRef{Number: 1, Title: "Chapter 1: The Beginning", Volume: 0}, + Text: "The first chapter text with enough content to be meaningful for a real novel chapter.", + }, + { + Ref: scraper.ChapterRef{Number: 2, Title: "Chapter 2: Rising Action", Volume: 0}, + Text: "The second chapter text continues the story from where the first left off.", + }, + { + Ref: scraper.ChapterRef{Number: 3, Title: "Chapter 3: Climax", Volume: 0}, + Text: "The third chapter text reaches the peak of tension and conflict.", + }, + } + + t.Run("WriteChapter", func(t *testing.T) { + for _, ch := range chapters { + if err := hs.WriteChapter(ctx, slug, ch); err != nil { + t.Fatalf("WriteChapter(%d): %v", ch.Ref.Number, err) + } + t.Logf("wrote chapter %d", ch.Ref.Number) + } + }) + + t.Run("ChapterExists", func(t *testing.T) { + for _, ch := range chapters { + if !hs.ChapterExists(ctx, slug, ch.Ref) { + t.Errorf("ChapterExists(chapter %d) = false after WriteChapter", ch.Ref.Number) + } + } + missing := scraper.ChapterRef{Number: 999, Volume: 0} + if hs.ChapterExists(ctx, slug, missing) { + t.Error("ChapterExists(999) = true for a chapter that was never written") + } + }) + + t.Run("ReadChapter", func(t *testing.T) { + for _, ch := range chapters { + got, err := hs.ReadChapter(ctx, slug, ch.Ref.Number) + if err != nil { + t.Fatalf("ReadChapter(%d): %v", ch.Ref.Number, err) + } + // WriteChapter prepends "# \n\n" and appends "\n". + expectedPrefix := "# " + ch.Ref.Title + if !strings.HasPrefix(got, expectedPrefix) { + t.Errorf("chapter %d: content doesn't start with expected header\ngot: %q\nwant prefix: %q", + ch.Ref.Number, got[:min(len(got), 80)], expectedPrefix) + } + if !strings.Contains(got, ch.Text) { + t.Errorf("chapter %d: content doesn't contain original text", ch.Ref.Number) + } + t.Logf("chapter %d: %d bytes", ch.Ref.Number, len(got)) + } + }) + + t.Run("CountChapters", func(t *testing.T) { + count := hs.CountChapters(ctx, slug) + if count != len(chapters) { + t.Errorf("CountChapters = %d, want %d", count, len(chapters)) + } + }) + + t.Run("ListChapters", func(t *testing.T) { + infos, err := hs.ListChapters(ctx, slug) + if err != nil { + t.Fatalf("ListChapters: %v", err) + } + if len(infos) != len(chapters) { + t.Errorf("ListChapters returned %d entries, want %d", len(infos), len(chapters)) + } + for i, info := range infos { + t.Logf("infos[%d]: number=%d title=%q date=%q", i, info.Number, info.Title, info.Date) + } + // Verify sorted order. + for i := 1; i < len(infos); i++ { + if infos[i].Number <= infos[i-1].Number { + t.Errorf("ListChapters not sorted: infos[%d].Number=%d <= infos[%d].Number=%d", + i, infos[i].Number, i-1, infos[i-1].Number) + } + } + }) +} + +// TestHybridStore_WriteReadRanking exercises WriteRankingItem → ReadRankingItems +// round-trip and RankingFreshEnough. +func TestHybridStore_WriteReadRanking(t *testing.T) { + hs := newTestHybridStore(t) + slug1 := "integ-rank-1-" + fmt.Sprintf("%d", time.Now().UnixMilli()) + slug2 := "integ-rank-2-" + fmt.Sprintf("%d", time.Now().UnixMilli()) + slug3 := "integ-rank-3-" + fmt.Sprintf("%d", time.Now().UnixMilli()) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + t.Cleanup(func() { + cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + for _, sl := range []string{slug1, slug2, slug3} { + _ = hs.pb.pb.deleteWhere(cleanCtx, "ranking", fmt.Sprintf(`slug="%s"`, sl)) + } + }) + + items := []RankingItem{ + {Rank: 1, Slug: slug1, Title: "Top Novel", Author: "Author A", Status: "Ongoing", SourceURL: "https://example.com/book/top"}, + {Rank: 2, Slug: slug2, Title: "Second Novel", Author: "Author B", Genres: []string{"Action"}, Status: "Completed"}, + {Rank: 3, Slug: slug3, Title: "Third Novel"}, + } + + t.Run("WriteRankingItem", func(t *testing.T) { + for _, item := range items { + if err := hs.WriteRankingItem(ctx, item); err != nil { + t.Fatalf("WriteRankingItem(%s): %v", item.Slug, err) + } + } + t.Logf("wrote %d ranking items", len(items)) + }) + + t.Run("ReadRankingItems", func(t *testing.T) { + got, err := hs.ReadRankingItems(ctx) + if err != nil { + t.Fatalf("ReadRankingItems: %v", err) + } + // Filter to just our test slugs (other tests may leave rows). + var ours []RankingItem + slugSet := map[string]bool{slug1: true, slug2: true, slug3: true} + for _, g := range got { + if slugSet[g.Slug] { + ours = append(ours, g) + } + } + if len(ours) != 3 { + t.Fatalf("ReadRankingItems returned %d test items, want 3", len(ours)) + } + // Verify order by rank. + for i := 1; i < len(ours); i++ { + if ours[i].Rank <= ours[i-1].Rank { + t.Errorf("items not sorted by rank: ours[%d].Rank=%d, ours[%d].Rank=%d", + i, ours[i].Rank, i-1, ours[i-1].Rank) + } + } + // Verify fields. + if ours[0].Title != "Top Novel" { + t.Errorf("ours[0].Title = %q, want %q", ours[0].Title, "Top Novel") + } + if ours[0].Author != "Author A" { + t.Errorf("ours[0].Author = %q, want %q", ours[0].Author, "Author A") + } + t.Logf("ranking items: %+v", ours) + }) + + t.Run("RankingFreshEnough", func(t *testing.T) { + fresh, err := hs.RankingFreshEnough(ctx, 24*time.Hour) + if err != nil { + t.Fatalf("RankingFreshEnough: %v", err) + } + if !fresh { + t.Error("RankingFreshEnough(24h) returned false immediately after writing items") + } + t.Logf("ranking fresh=true") + }) +} + +// TestHybridStore_Progress exercises SetProgress → GetProgress → AllProgress → +// DeleteProgress via the HybridStore. +func TestHybridStore_Progress(t *testing.T) { + hs := newTestHybridStore(t) + slug := testSlug(t) + const sessionID = "hybrid-test-session-abc" + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + t.Cleanup(func() { + cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = hs.pb.pb.deleteWhere(cleanCtx, "progress", + fmt.Sprintf(`session_id="%s"`, sessionID)) + }) + + p := ReadingProgress{Slug: slug, Chapter: 7, UpdatedAt: time.Now()} + + t.Run("SetProgress", func(t *testing.T) { + if err := hs.SetProgress(ctx, sessionID, p); err != nil { + t.Fatalf("SetProgress: %v", err) + } + }) + + t.Run("GetProgress", func(t *testing.T) { + got, ok := hs.GetProgress(ctx, sessionID, slug) + if !ok { + t.Fatal("GetProgress: not found after SetProgress") + } + if got.Chapter != 7 { + t.Errorf("Chapter = %d, want 7", got.Chapter) + } + if got.Slug != slug { + t.Errorf("Slug = %q, want %q", got.Slug, slug) + } + t.Logf("progress: chapter=%d slug=%q updated=%s", got.Chapter, got.Slug, got.UpdatedAt) + }) + + t.Run("AllProgress", func(t *testing.T) { + all, err := hs.AllProgress(ctx, sessionID) + if err != nil { + t.Fatalf("AllProgress: %v", err) + } + found := false + for _, item := range all { + if item.Slug == slug { + found = true + } + } + if !found { + t.Errorf("AllProgress did not contain slug %q (total=%d)", slug, len(all)) + } + }) + + t.Run("DeleteProgress", func(t *testing.T) { + if err := hs.DeleteProgress(ctx, sessionID, slug); err != nil { + t.Fatalf("DeleteProgress: %v", err) + } + _, ok := hs.GetProgress(ctx, sessionID, slug) + if ok { + t.Error("GetProgress returned ok=true after DeleteProgress") + } + }) +} + +// TestHybridStore_PresignChapter writes a chapter to MinIO via HybridStore, +// then calls PresignChapter and verifies a non-empty URL is returned. +func TestHybridStore_PresignChapter(t *testing.T) { + hs := newTestHybridStore(t) + slug := testSlug(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + ch := scraper.Chapter{ + Ref: scraper.ChapterRef{Number: 1, Title: "Chapter 1: Presign Test", Volume: 0}, + Text: "Text for the presign chapter test.", + } + + if err := hs.WriteChapter(ctx, slug, ch); err != nil { + t.Fatalf("WriteChapter: %v", err) + } + + url, err := hs.PresignChapter(ctx, slug, 1, 10*time.Minute) + if err != nil { + t.Fatalf("PresignChapter: %v", err) + } + if url == "" { + t.Fatal("PresignChapter returned empty URL") + } + if !strings.HasPrefix(url, "http") { + t.Errorf("PresignChapter URL does not start with http: %q", url) + } + t.Logf("presigned chapter URL: %s", url) +} + +// TestHybridStore_PresignAudio puts a fake audio blob into MinIO via the +// underlying MinioClient and verifies PresignAudio returns a valid URL. +func TestHybridStore_PresignAudio(t *testing.T) { + hs := newTestHybridStore(t) + slug := testSlug(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + key := hs.AudioObjectKey(slug, 1, "af_bella") + fakeAudio := []byte("ID3\x03\x00\x00\x00\x00\x00\x00hybrid-presign-audio-test") + + if err := hs.minio.PutAudio(ctx, key, fakeAudio); err != nil { + t.Fatalf("PutAudio: %v", err) + } + + url, err := hs.PresignAudio(ctx, key, 10*time.Minute) + if err != nil { + t.Fatalf("PresignAudio: %v", err) + } + if url == "" { + t.Fatal("PresignAudio returned empty URL") + } + if !strings.HasPrefix(url, "http") { + t.Errorf("PresignAudio URL does not start with http: %q", url) + } + t.Logf("presigned audio URL: %s", url) +} + +// TestHybridStore_AudioCache exercises SetAudioCache → GetAudioCache via HybridStore. +func TestHybridStore_AudioCache(t *testing.T) { + hs := newTestHybridStore(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cacheKey := fmt.Sprintf("hybrid-audio-test-%d", time.Now().UnixMilli()) + const filename = "speech_hybrid123.mp3" + + t.Cleanup(func() { + cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = hs.pb.pb.deleteWhere(cleanCtx, "audio_cache", + fmt.Sprintf(`cache_key="%s"`, cacheKey)) + }) + + if err := hs.SetAudioCache(ctx, cacheKey, filename); err != nil { + t.Fatalf("SetAudioCache: %v", err) + } + + got, ok := hs.GetAudioCache(ctx, cacheKey) + if !ok { + t.Fatal("GetAudioCache returned ok=false after SetAudioCache") + } + if got != filename { + t.Errorf("filename = %q, want %q", got, filename) + } + t.Logf("audio cache: cacheKey=%q filename=%q", cacheKey, got) +} + +// ─── helpers ────────────────────────────────────────────────────────────────── diff --git a/scraper/internal/storage/hybrid_unit_test.go b/scraper/internal/storage/hybrid_unit_test.go new file mode 100644 index 0000000..c90f9f7 --- /dev/null +++ b/scraper/internal/storage/hybrid_unit_test.go @@ -0,0 +1,77 @@ +package storage + +import ( + "testing" +) + +// ── chapterNumberFromKey ────────────────────────────────────────────────────── + +func TestChapterNumberFromKey(t *testing.T) { + cases := []struct { + key string + want int + }{ + // Standard four-segment key. + {"my-novel/vol-0/1-50/chapter-1.md", 1}, + {"my-novel/vol-0/1-50/chapter-42.md", 42}, + {"my-novel/vol-0/51-100/chapter-99.md", 99}, + // Large chapter numbers. + {"some-novel/vol-1/1001-1050/chapter-1024.md", 1024}, + // Nested deeper paths should still work (last segment used). + {"a/b/c/d/chapter-7.md", 7}, + // Malformed / unexpected inputs — should return 0 without panicking. + {"chapter-notanumber.md", 0}, + {"", 0}, + // No .md extension — TrimSuffix is a no-op; TrimPrefix still strips + // "chapter-", so the number is parsed successfully. + {"no-md-extension/chapter-5", 5}, + {"my-novel/vol-0/1-50/chapter-0.md", 0}, // 0 is invalid (chapters are 1-based) + {"my-novel/vol-0/1-50/chapter--1.md", 0}, + } + + for _, tc := range cases { + got := chapterNumberFromKey(tc.key) + if got != tc.want { + t.Errorf("chapterNumberFromKey(%q) = %d, want %d", tc.key, got, tc.want) + } + } +} + +// ── splitChapterTitle ───────────────────────────────────────────────────────── + +func TestSplitChapterTitle(t *testing.T) { + cases := []struct { + raw string + wantTitle string + wantDate string + }{ + // No date — title is returned as-is. + {"The Great Battle", "The Great Battle", ""}, + // Leading numeric index is stripped. + {"42 The Great Battle", "The Great Battle", ""}, + // Relative date with plural unit. + {"The Storm Arrives 3 days ago", "The Storm Arrives", "3 days ago"}, + // Singular unit. + {"A New Hope 1 week ago", "A New Hope", "1 week ago"}, + // Minutes and seconds. + {"Flash Fight 5 minutes ago", "Flash Fight", "5 minutes ago"}, + {"Quick Strike 30 seconds ago", "Quick Strike", "30 seconds ago"}, + // Months and years. + {"Old Chapter 2 months ago", "Old Chapter", "2 months ago"}, + {"Ancient Story 1 year ago", "Ancient Story", "1 year ago"}, + // Leading index AND trailing date. + {"5 The Final Chapter 2 hours ago", "The Final Chapter", "2 hours ago"}, + // Extra whitespace. + {" The Calm ", "The Calm", ""}, + // Empty string. + {"", "", ""}, + } + + for _, tc := range cases { + title, date := splitChapterTitle(tc.raw) + if title != tc.wantTitle || date != tc.wantDate { + t.Errorf("splitChapterTitle(%q) = (%q, %q), want (%q, %q)", + tc.raw, title, date, tc.wantTitle, tc.wantDate) + } + } +} diff --git a/scraper/internal/storage/integration_test.go b/scraper/internal/storage/integration_test.go new file mode 100644 index 0000000..a7da7b7 --- /dev/null +++ b/scraper/internal/storage/integration_test.go @@ -0,0 +1,655 @@ +//go:build integration + +// Integration tests for MinioClient and PocketBaseStore against live instances. +// +// These tests require running MinIO and PocketBase services. They are gated +// behind the "integration" build tag and are never run in a normal `go test ./...`. +// +// Run with: +// +// MINIO_ENDPOINT=localhost:9000 \ +// POCKETBASE_URL=http://localhost:8090 \ +// go test -v -tags integration -timeout 120s \ +// github.com/libnovel/scraper/internal/storage +package storage + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "testing" + "time" +) + +// ─── helpers ────────────────────────────────────────────────────────────────── + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func newTestMinioClient(t *testing.T) *MinioClient { + t.Helper() + endpoint := os.Getenv("MINIO_ENDPOINT") + if endpoint == "" { + t.Skip("MINIO_ENDPOINT not set — skipping MinIO integration test") + } + useSSL := os.Getenv("MINIO_USE_SSL") == "true" + cfg := MinioConfig{ + Endpoint: endpoint, + AccessKey: envOr("MINIO_ACCESS_KEY", "admin"), + SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"), + UseSSL: useSSL, + BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"), + BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"), + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + mc, err := NewMinioClient(ctx, cfg) + if err != nil { + t.Fatalf("NewMinioClient: %v", err) + } + return mc +} + +func newTestPocketBaseStore(t *testing.T) *PocketBaseStore { + t.Helper() + pbURL := os.Getenv("POCKETBASE_URL") + if pbURL == "" { + t.Skip("POCKETBASE_URL not set — skipping PocketBase integration test") + } + cfg := PocketBaseConfig{ + BaseURL: pbURL, + AdminEmail: envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local"), + AdminPassword: envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123"), + } + store := NewPocketBaseStore(cfg, slog.Default()) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := store.EnsureCollections(ctx); err != nil { + t.Logf("EnsureCollections (may be harmless): %v", err) + } + return store +} + +// testSlug generates a unique test slug to avoid collisions between parallel runs. +func testSlug(t *testing.T) string { + t.Helper() + safe := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + return r + } + return '-' + }, strings.ToLower(t.Name())) + // Truncate and append a timestamp to keep it unique. + if len(safe) > 30 { + safe = safe[:30] + } + return fmt.Sprintf("test-%s-%d", safe, time.Now().UnixMilli()%100000) +} + +// ─── MinioClient tests ──────────────────────────────────────────────────────── + +// TestMinioClient_ChapterRoundTrip verifies PutChapter → GetChapter → +// ChapterExists → ListChapterKeys for a single chapter. +func TestMinioClient_ChapterRoundTrip(t *testing.T) { + mc := newTestMinioClient(t) + slug := testSlug(t) + const vol = 0 + const n = 1 + content := "# Chapter 1\n\nHello integration world.\n" + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + t.Run("PutChapter", func(t *testing.T) { + if err := mc.PutChapter(ctx, slug, vol, n, content); err != nil { + t.Fatalf("PutChapter: %v", err) + } + t.Logf("stored chapter at key: %s", chapterKey(slug, vol, n)) + }) + + t.Run("GetChapter", func(t *testing.T) { + got, err := mc.GetChapter(ctx, slug, vol, n) + if err != nil { + t.Fatalf("GetChapter: %v", err) + } + if got != content { + t.Errorf("GetChapter round-trip mismatch:\ngot: %q\nwant: %q", got, content) + } + t.Logf("retrieved %d bytes", len(got)) + }) + + t.Run("ChapterExists", func(t *testing.T) { + if !mc.ChapterExists(ctx, slug, vol, n) { + t.Error("ChapterExists returned false for a just-stored chapter") + } + if mc.ChapterExists(ctx, slug, vol, 999) { + t.Error("ChapterExists returned true for a chapter that was never stored") + } + }) + + t.Run("ListChapterKeys", func(t *testing.T) { + keys, err := mc.ListChapterKeys(ctx, slug) + if err != nil { + t.Fatalf("ListChapterKeys: %v", err) + } + if len(keys) != 1 { + t.Fatalf("ListChapterKeys returned %d keys, want 1: %v", len(keys), keys) + } + expectedKey := chapterKey(slug, vol, n) + if keys[0] != expectedKey { + t.Errorf("key = %q, want %q", keys[0], expectedKey) + } + t.Logf("keys: %v", keys) + }) +} + +// TestMinioClient_MultiChapterList stores several chapters and verifies +// ListChapterKeys returns them all. +func TestMinioClient_MultiChapterList(t *testing.T) { + mc := newTestMinioClient(t) + slug := testSlug(t) + const vol = 0 + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Store chapters 1, 2, 51 (crosses the 1-50 folder boundary). + chapters := []int{1, 2, 51} + for _, n := range chapters { + content := fmt.Sprintf("# Chapter %d\n\nContent for chapter %d.\n", n, n) + if err := mc.PutChapter(ctx, slug, vol, n, content); err != nil { + t.Fatalf("PutChapter(%d): %v", n, err) + } + } + + keys, err := mc.ListChapterKeys(ctx, slug) + if err != nil { + t.Fatalf("ListChapterKeys: %v", err) + } + t.Logf("keys: %v", keys) + if len(keys) != len(chapters) { + t.Errorf("ListChapterKeys returned %d keys, want %d", len(keys), len(chapters)) + } + + count := mc.CountChapters(ctx, slug) + if count != len(chapters) { + t.Errorf("CountChapters = %d, want %d", count, len(chapters)) + } +} + +// TestMinioClient_PresignChapter verifies PresignChapter returns a non-empty URL. +func TestMinioClient_PresignChapter(t *testing.T) { + mc := newTestMinioClient(t) + slug := testSlug(t) + const vol = 0 + const n = 1 + content := "# Presign test\n\nSome content.\n" + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := mc.PutChapter(ctx, slug, vol, n, content); err != nil { + t.Fatalf("PutChapter: %v", err) + } + + url, err := mc.PresignChapter(ctx, slug, vol, n, 10*time.Minute) + if err != nil { + t.Fatalf("PresignChapter: %v", err) + } + if url == "" { + t.Fatal("PresignChapter returned empty URL") + } + t.Logf("presigned URL: %s", url) + + // URL must be an http(s) URL and contain the slug somewhere. + if !strings.HasPrefix(url, "http") { + t.Errorf("URL does not start with http: %q", url) + } +} + +// TestMinioClient_AudioRoundTrip verifies PutAudio → GetAudio → AudioExists. +func TestMinioClient_AudioRoundTrip(t *testing.T) { + mc := newTestMinioClient(t) + slug := testSlug(t) + key := AudioObjectKey(slug, 1, "af_bella") + + // Use minimal fake MP3 bytes (just a recognisable prefix). + fakeAudio := []byte("ID3\x03\x00\x00\x00\x00\x00\x00integration-test-audio") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + t.Run("PutAudio", func(t *testing.T) { + if err := mc.PutAudio(ctx, key, fakeAudio); err != nil { + t.Fatalf("PutAudio: %v", err) + } + t.Logf("stored audio at key: %s", key) + }) + + t.Run("GetAudio", func(t *testing.T) { + got, err := mc.GetAudio(ctx, key) + if err != nil { + t.Fatalf("GetAudio: %v", err) + } + if string(got) != string(fakeAudio) { + t.Errorf("GetAudio round-trip mismatch: got %d bytes, want %d", len(got), len(fakeAudio)) + } + t.Logf("retrieved %d bytes", len(got)) + }) + + t.Run("AudioExists", func(t *testing.T) { + if !mc.AudioExists(ctx, key) { + t.Error("AudioExists returned false for a just-stored audio object") + } + if mc.AudioExists(ctx, "nonexistent/key.mp3") { + t.Error("AudioExists returned true for a key that was never stored") + } + }) +} + +// TestMinioClient_PresignAudio verifies PresignAudio returns a non-empty URL. +func TestMinioClient_PresignAudio(t *testing.T) { + mc := newTestMinioClient(t) + slug := testSlug(t) + key := AudioObjectKey(slug, 1, "af_bella") + fakeAudio := []byte("ID3\x03\x00\x00\x00\x00\x00\x00presign-audio-test") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := mc.PutAudio(ctx, key, fakeAudio); err != nil { + t.Fatalf("PutAudio: %v", err) + } + + url, err := mc.PresignAudio(ctx, key, 10*time.Minute) + if err != nil { + t.Fatalf("PresignAudio: %v", err) + } + if url == "" { + t.Fatal("PresignAudio returned empty URL") + } + if !strings.HasPrefix(url, "http") { + t.Errorf("URL does not start with http: %q", url) + } + t.Logf("presigned audio URL: %s", url) +} + +// ─── PocketBaseStore tests ──────────────────────────────────────────────────── + +// TestPocketBaseStore_Ping verifies that admin auth works. +func TestPocketBaseStore_Ping(t *testing.T) { + store := newTestPocketBaseStore(t) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := store.Ping(ctx); err != nil { + t.Fatalf("Ping: %v", err) + } + t.Log("Ping succeeded") +} + +// TestPocketBaseStore_BookRoundTrip tests UpsertBook → GetBook → ListBooks → +// BookMetaUpdated. +func TestPocketBaseStore_BookRoundTrip(t *testing.T) { + store := newTestPocketBaseStore(t) + slug := testSlug(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Clean up after test. + t.Cleanup(func() { + cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = store.pb.deleteWhere(cleanCtx, "books", fmt.Sprintf(`slug="%s"`, slug)) + }) + + t.Run("UpsertBook_Create", func(t *testing.T) { + err := store.UpsertBook(ctx, slug, + "Integration Test Novel", "Test Author", + "https://example.com/cover.jpg", "Ongoing", + "A test summary.", "https://example.com/book/test", + []string{"Action", "Fantasy"}, 42, 7, + ) + if err != nil { + t.Fatalf("UpsertBook (create): %v", err) + } + t.Logf("created book %q", slug) + }) + + t.Run("GetBook", func(t *testing.T) { + rec, found, err := store.GetBook(ctx, slug) + if err != nil { + t.Fatalf("GetBook: %v", err) + } + if !found { + t.Fatal("GetBook: book not found after UpsertBook") + } + t.Logf("GetBook record: %v", rec) + if rec["title"] != "Integration Test Novel" { + t.Errorf("title = %v, want %q", rec["title"], "Integration Test Novel") + } + if rec["author"] != "Test Author" { + t.Errorf("author = %v, want %q", rec["author"], "Test Author") + } + }) + + t.Run("ListBooks", func(t *testing.T) { + books, err := store.ListBooks(ctx) + if err != nil { + t.Fatalf("ListBooks: %v", err) + } + found := false + for _, b := range books { + if s, _ := b["slug"].(string); s == slug { + found = true + break + } + } + if !found { + t.Errorf("ListBooks did not return book with slug %q (total=%d)", slug, len(books)) + } + }) + + t.Run("UpsertBook_Update", func(t *testing.T) { + err := store.UpsertBook(ctx, slug, + "Integration Test Novel", "Test Author Updated", + "", "Completed", "", "https://example.com/book/test", + nil, 100, 3, + ) + if err != nil { + t.Fatalf("UpsertBook (update): %v", err) + } + rec, found, err := store.GetBook(ctx, slug) + if err != nil || !found { + t.Fatalf("GetBook after update: found=%v err=%v", found, err) + } + if rec["author"] != "Test Author Updated" { + t.Errorf("author after update = %v, want %q", rec["author"], "Test Author Updated") + } + if rec["status"] != "Completed" { + t.Errorf("status after update = %v, want %q", rec["status"], "Completed") + } + }) + + t.Run("BookMetaUpdated", func(t *testing.T) { + ts, err := store.BookMetaUpdated(ctx, slug) + if err != nil { + t.Fatalf("BookMetaUpdated: %v", err) + } + if ts.IsZero() { + t.Error("BookMetaUpdated returned zero time") + } + t.Logf("meta_updated: %s", ts) + }) +} + +// TestPocketBaseStore_ChapterIdx tests UpsertChapterIdx → ListChapterIdx → +// CountChapterIdx. +func TestPocketBaseStore_ChapterIdx(t *testing.T) { + store := newTestPocketBaseStore(t) + slug := testSlug(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + t.Cleanup(func() { + cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = store.pb.deleteWhere(cleanCtx, "chapters_idx", fmt.Sprintf(`slug="%s"`, slug)) + }) + + chapters := []struct { + n int + title string + date string + }{ + {1, "Chapter 1: The Beginning", "2 days ago"}, + {2, "Chapter 2: Rising Action", "1 day ago"}, + {3, "Chapter 3: Climax", "3 hours ago"}, + } + + for _, ch := range chapters { + if err := store.UpsertChapterIdx(ctx, slug, ch.n, ch.title, ch.date); err != nil { + t.Fatalf("UpsertChapterIdx(%d): %v", ch.n, err) + } + } + + t.Run("ListChapterIdx", func(t *testing.T) { + rows, err := store.ListChapterIdx(ctx, slug) + if err != nil { + t.Fatalf("ListChapterIdx: %v", err) + } + if len(rows) != len(chapters) { + t.Errorf("ListChapterIdx returned %d rows, want %d", len(rows), len(chapters)) + } + for i, row := range rows { + t.Logf("row[%d]: number=%v title=%v date_label=%v", i, row["number"], row["title"], row["date_label"]) + } + }) + + t.Run("CountChapterIdx", func(t *testing.T) { + count := store.CountChapterIdx(ctx, slug) + if count != len(chapters) { + t.Errorf("CountChapterIdx = %d, want %d", count, len(chapters)) + } + }) + + t.Run("UpsertChapterIdx_Update", func(t *testing.T) { + // Re-upsert chapter 2 with an updated title. + if err := store.UpsertChapterIdx(ctx, slug, 2, "Chapter 2: Revised Title", "1 day ago"); err != nil { + t.Fatalf("UpsertChapterIdx (update): %v", err) + } + rows, err := store.ListChapterIdx(ctx, slug) + if err != nil { + t.Fatalf("ListChapterIdx after update: %v", err) + } + if store.CountChapterIdx(ctx, slug) != len(chapters) { + t.Errorf("count changed after update: got %d, want %d", len(rows), len(chapters)) + } + }) +} + +// TestPocketBaseStore_Ranking tests SetRanking → GetRanking → RankingModTime. +func TestPocketBaseStore_Ranking(t *testing.T) { + store := newTestPocketBaseStore(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + slug1 := testSlug(t) + "-rank1" + slug2 := testSlug(t) + "-rank2" + + t.Cleanup(func() { + cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + for _, sl := range []string{slug1, slug2} { + _ = store.pb.deleteWhere(cleanCtx, "ranking", fmt.Sprintf(`slug="%s"`, sl)) + } + }) + + items := []RankingItem{ + {Rank: 1, Slug: slug1, Title: "Test Book One", SourceURL: "https://example.com/1"}, + {Rank: 2, Slug: slug2, Title: "Test Book Two", SourceURL: "https://example.com/2"}, + } + + t.Run("WriteRankingItem", func(t *testing.T) { + for _, item := range items { + if err := store.UpsertRankingItem(ctx, item); err != nil { + t.Fatalf("UpsertRankingItem(%q): %v", item.Slug, err) + } + } + t.Log("UpsertRankingItem succeeded") + }) + + t.Run("ReadRankingItems", func(t *testing.T) { + got, err := store.ListRankingItems(ctx) + if err != nil { + t.Fatalf("ListRankingItems: %v", err) + } + found := 0 + for _, g := range got { + if g.Slug == slug1 || g.Slug == slug2 { + found++ + } + } + if found != 2 { + t.Errorf("ListRankingItems: found %d of 2 test items in %d total", found, len(got)) + } + t.Logf("ListRankingItems returned %d total items, %d test items", len(got), found) + }) + + t.Run("RankingFreshEnough", func(t *testing.T) { + updated, err := store.RankingLastUpdated(ctx) + if err != nil { + t.Fatalf("RankingLastUpdated: %v", err) + } + if updated.IsZero() { + t.Error("RankingLastUpdated returned zero time immediately after write") + } + fresh := time.Since(updated) < 24*time.Hour + if !fresh { + t.Errorf("RankingLastUpdated = %s; want within 24h", updated) + } + t.Logf("RankingLastUpdated = %s (fresh=%v)", updated, fresh) + }) +} + +// TestPocketBaseStore_Progress tests SetProgress → GetProgress → AllProgress → +// DeleteProgress. +func TestPocketBaseStore_Progress(t *testing.T) { + store := newTestPocketBaseStore(t) + slug := testSlug(t) + const sessionID = "integration-test-session-xyz" + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + t.Cleanup(func() { + cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = store.pb.deleteWhere(cleanCtx, "progress", + fmt.Sprintf(`session_id="%s"`, sessionID)) + }) + + t.Run("SetProgress", func(t *testing.T) { + if err := store.SetProgress(ctx, sessionID, slug, 5); err != nil { + t.Fatalf("SetProgress: %v", err) + } + }) + + t.Run("GetProgress", func(t *testing.T) { + ch, updated, found, err := store.GetProgress(ctx, sessionID, slug) + if err != nil { + t.Fatalf("GetProgress: %v", err) + } + if !found { + t.Fatal("GetProgress: not found after SetProgress") + } + if ch != 5 { + t.Errorf("chapter = %d, want 5", ch) + } + if updated.IsZero() { + t.Error("updated time is zero") + } + t.Logf("chapter=%d updated=%s", ch, updated) + }) + + t.Run("AllProgress", func(t *testing.T) { + rows, err := store.AllProgress(ctx, sessionID) + if err != nil { + t.Fatalf("AllProgress: %v", err) + } + found := false + for _, r := range rows { + if s, _ := r["slug"].(string); s == slug { + found = true + } + } + if !found { + t.Errorf("AllProgress did not include slug %q (total=%d)", slug, len(rows)) + } + }) + + t.Run("SetProgress_Update", func(t *testing.T) { + if err := store.SetProgress(ctx, sessionID, slug, 12); err != nil { + t.Fatalf("SetProgress (update): %v", err) + } + ch, _, found, err := store.GetProgress(ctx, sessionID, slug) + if err != nil || !found { + t.Fatalf("GetProgress after update: found=%v err=%v", found, err) + } + if ch != 12 { + t.Errorf("chapter after update = %d, want 12", ch) + } + }) + + t.Run("DeleteProgress", func(t *testing.T) { + if err := store.DeleteProgress(ctx, sessionID, slug); err != nil { + t.Fatalf("DeleteProgress: %v", err) + } + _, _, found, err := store.GetProgress(ctx, sessionID, slug) + if err != nil { + t.Fatalf("GetProgress after delete: %v", err) + } + if found { + t.Error("GetProgress returned found=true after DeleteProgress") + } + t.Log("DeleteProgress confirmed") + }) +} + +// TestPocketBaseStore_AudioCache tests SetAudioCache → GetAudioCache. +func TestPocketBaseStore_AudioCache(t *testing.T) { + store := newTestPocketBaseStore(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cacheKey := fmt.Sprintf("integration-audio-cache-test-%d", time.Now().UnixMilli()) + const filename = "speech_abc123.mp3" + + t.Cleanup(func() { + cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = store.pb.deleteWhere(cleanCtx, "audio_cache", + fmt.Sprintf(`cache_key="%s"`, cacheKey)) + }) + + t.Run("SetAudioCache", func(t *testing.T) { + if err := store.SetAudioCache(ctx, cacheKey, filename); err != nil { + t.Fatalf("SetAudioCache: %v", err) + } + }) + + t.Run("GetAudioCache", func(t *testing.T) { + got, found, err := store.GetAudioCache(ctx, cacheKey) + if err != nil { + t.Fatalf("GetAudioCache: %v", err) + } + if !found { + t.Fatal("GetAudioCache: not found after SetAudioCache") + } + if got != filename { + t.Errorf("filename = %q, want %q", got, filename) + } + t.Logf("filename: %s", got) + }) + + t.Run("GetAudioCache_Miss", func(t *testing.T) { + got, found, err := store.GetAudioCache(ctx, "does-not-exist-ever") + if err != nil { + t.Fatalf("GetAudioCache (miss): %v", err) + } + if found { + t.Errorf("GetAudioCache returned found=true for missing key, filename=%q", got) + } + }) +} diff --git a/scraper/internal/storage/minio.go b/scraper/internal/storage/minio.go new file mode 100644 index 0000000..77589bc --- /dev/null +++ b/scraper/internal/storage/minio.go @@ -0,0 +1,420 @@ +package storage + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// MinioConfig holds connection parameters for MinIO. +type MinioConfig struct { + Endpoint string // e.g. "minio:9000" — internal address used for all operations + PublicEndpoint string // e.g. "minio.kalekber.cc" — used to sign presigned URLs so browsers can reach them; leave empty to use Endpoint + AccessKey string + SecretKey string + UseSSL bool + PublicUseSSL bool // TLS for the public endpoint (usually true in prod) + BucketChapters string // e.g. "libnovel-chapters" + BucketAudio string // e.g. "libnovel-audio" + BucketBrowse string // e.g. "libnovel-browse" + BucketAvatars string // e.g. "libnovel-avatars" +} + +// MinioClient wraps a minio.Client and exposes object operations for +// chapters and audio files. +type MinioClient struct { + c *minio.Client // internal client — used for all read/write operations + pub *minio.Client // public client — used only for generating presigned URLs + cfg MinioConfig +} + +// NewMinioClient creates a connected MinIO client and ensures the required +// buckets exist. +func NewMinioClient(ctx context.Context, cfg MinioConfig) (*MinioClient, error) { + // minio-go expects a bare "host:port" endpoint — strip any scheme prefix that + // callers may accidentally include (e.g. "https://minio.example.com"). + cfg.Endpoint = strings.TrimPrefix(strings.TrimPrefix(cfg.Endpoint, "https://"), "http://") + if cfg.PublicEndpoint != "" { + cfg.PublicEndpoint = strings.TrimPrefix(strings.TrimPrefix(cfg.PublicEndpoint, "https://"), "http://") + } + + c, err := minio.New(cfg.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.UseSSL, + }) + if err != nil { + return nil, fmt.Errorf("minio: new client: %w", err) + } + + // Public client: signs presigned URLs with the public hostname so browsers + // can fetch them directly. Falls back to the internal client if no public + // endpoint is configured. + pub := c + if cfg.PublicEndpoint != "" && cfg.PublicEndpoint != cfg.Endpoint { + pub, err = minio.New(cfg.PublicEndpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.PublicUseSSL, + }) + if err != nil { + return nil, fmt.Errorf("minio: new public client: %w", err) + } + } + + mc := &MinioClient{c: c, pub: pub, cfg: cfg} + for _, bucket := range []string{cfg.BucketChapters, cfg.BucketAudio, cfg.BucketBrowse, cfg.BucketAvatars} { + if bucket == "" { + continue + } + if err := mc.ensureBucket(ctx, bucket); err != nil { + return nil, err + } + } + return mc, nil +} + +// ensureBucket creates a bucket if it does not exist. +func (m *MinioClient) ensureBucket(ctx context.Context, bucket string) error { + exists, err := m.c.BucketExists(ctx, bucket) + if err != nil { + return fmt.Errorf("minio: bucket exists %q: %w", bucket, err) + } + if !exists { + if err := m.c.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil { + return fmt.Errorf("minio: make bucket %q: %w", bucket, err) + } + } + return nil +} + +// ─── Chapter objects ────────────────────────────────────────────────────────── + +// chapterKey returns the MinIO object key for a chapter. +// Layout: {slug}/vol-{vol}/{lo}-{hi}/chapter-{n}.md +func chapterKey(slug string, vol, n int) string { + const chaptersPerFolder = 50 + lo := ((n-1)/chaptersPerFolder)*chaptersPerFolder + 1 + hi := lo + chaptersPerFolder - 1 + return fmt.Sprintf("%s/vol-%d/%d-%d/chapter-%d.md", slug, vol, lo, hi, n) +} + +// PutChapter stores chapter markdown in MinIO. +func (m *MinioClient) PutChapter(ctx context.Context, slug string, vol, n int, content string) error { + key := chapterKey(slug, vol, n) + data := []byte(content) + _, err := m.c.PutObject(ctx, m.cfg.BucketChapters, key, + bytes.NewReader(data), int64(len(data)), + minio.PutObjectOptions{ContentType: "text/markdown; charset=utf-8"}) + if err != nil { + return fmt.Errorf("minio: put chapter %s: %w", key, err) + } + return nil +} + +// GetChapter retrieves chapter markdown from MinIO. +func (m *MinioClient) GetChapter(ctx context.Context, slug string, vol, n int) (string, error) { + key := chapterKey(slug, vol, n) + obj, err := m.c.GetObject(ctx, m.cfg.BucketChapters, key, minio.GetObjectOptions{}) + if err != nil { + return "", fmt.Errorf("minio: get chapter %s: %w", key, err) + } + defer obj.Close() + data, err := io.ReadAll(obj) + if err != nil { + return "", fmt.Errorf("minio: read chapter %s: %w", key, err) + } + return string(data), nil +} + +// ChapterExists returns true if the object for this chapter is present. +func (m *MinioClient) ChapterExists(ctx context.Context, slug string, vol, n int) bool { + key := chapterKey(slug, vol, n) + _, err := m.c.StatObject(ctx, m.cfg.BucketChapters, key, minio.StatObjectOptions{}) + return err == nil +} + +// ListChapterKeys returns all object keys under slug/ in the chapters bucket, +// sorted lexicographically (MinIO returns them in order). +func (m *MinioClient) ListChapterKeys(ctx context.Context, slug string) ([]string, error) { + prefix := slug + "/" + var keys []string + for obj := range m.c.ListObjects(ctx, m.cfg.BucketChapters, + minio.ListObjectsOptions{Prefix: prefix, Recursive: true}) { + if obj.Err != nil { + return nil, fmt.Errorf("minio: list chapters %s: %w", slug, obj.Err) + } + keys = append(keys, obj.Key) + } + return keys, nil +} + +// CountChapters returns the number of chapter objects for a slug. +func (m *MinioClient) CountChapters(ctx context.Context, slug string) int { + keys, _ := m.ListChapterKeys(ctx, slug) + return len(keys) +} + +// ─── Audio objects ──────────────────────────────────────────────────────────── + +// AudioObjectKey returns the MinIO key for a cached audio file. +// Key: {slug}/ch{n}-{voice}.mp3 +func AudioObjectKey(slug string, n int, voice string) string { + safe := sanitiseVoice(voice) + return fmt.Sprintf("%s/ch%d-%s.mp3", slug, n, safe) +} + +// PutAudio stores an audio file in the audio bucket. +func (m *MinioClient) PutAudio(ctx context.Context, key string, data []byte) error { + _, err := m.c.PutObject(ctx, m.cfg.BucketAudio, key, + bytes.NewReader(data), int64(len(data)), + minio.PutObjectOptions{ContentType: "audio/mpeg"}) + if err != nil { + return fmt.Errorf("minio: put audio %s: %w", key, err) + } + return nil +} + +// GetAudio retrieves audio bytes from the audio bucket. +func (m *MinioClient) GetAudio(ctx context.Context, key string) ([]byte, error) { + obj, err := m.c.GetObject(ctx, m.cfg.BucketAudio, key, minio.GetObjectOptions{}) + if err != nil { + return nil, fmt.Errorf("minio: get audio %s: %w", key, err) + } + defer obj.Close() + return io.ReadAll(obj) +} + +// AudioExists returns true if the audio object is present in the bucket. +func (m *MinioClient) AudioExists(ctx context.Context, key string) bool { + _, err := m.c.StatObject(ctx, m.cfg.BucketAudio, key, minio.StatObjectOptions{}) + return err == nil +} + +// ─── Presigned URLs ─────────────────────────────────────────────────────────── + +// PresignChapter returns a presigned GET URL for a chapter object signed with +// the internal endpoint — intended for server-side fetches only. +func (m *MinioClient) PresignChapter(ctx context.Context, slug string, vol, n int, expires time.Duration) (string, error) { + key := chapterKey(slug, vol, n) + u, err := m.c.PresignedGetObject(ctx, m.cfg.BucketChapters, key, expires, nil) + if err != nil { + return "", fmt.Errorf("minio: presign chapter %s: %w", key, err) + } + return u.String(), nil +} + +// PresignAudio returns a presigned GET URL for an audio object signed with +// the public endpoint so the browser can fetch it directly. +func (m *MinioClient) PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error) { + u, err := m.pub.PresignedGetObject(ctx, m.cfg.BucketAudio, key, expires, nil) + if err != nil { + return "", fmt.Errorf("minio: presign audio %s: %w", key, err) + } + return u.String(), nil +} + +// ─── Browse page snapshots ──────────────────────────────────────────────────── +// +// New bucket layout (libnovel-browse): +// +// {domain}/html/page-{n}.html — SingleFile HTML snapshot +// {domain}/assets/book-covers/{slug}.jpg — downloaded cover image +// +// The domain segment is derived from the source URL hostname +// (e.g. "novelfire.net"). This makes the bucket self-describing and +// extensible to multiple sources. + +// BrowseHTMLKey returns the MinIO object key for a SingleFile HTML snapshot. +// Layout: {domain}/html/page-{n}.html +// This uses the default (popular/all/all) filter combination. +func BrowseHTMLKey(domain string, page int) string { + return fmt.Sprintf("%s/html/page-%d.html", domain, page) +} + +// BrowseFilteredHTMLKey returns the MinIO object key for a browse page snapshot +// that includes filter parameters (sort, genre, status) in the key so that +// different filter combinations are cached independently. +// Layout: {domain}/html/{sort}-{genre}-{status}/page-{n}.html +// Falls back to BrowseHTMLKey when all filters are at their default values +// (sort=popular, genre=all, status=all) for cache compatibility. +func BrowseFilteredHTMLKey(domain string, page int, sort, genre, status string) string { + if (sort == "" || sort == "popular") && (genre == "" || genre == "all") && (status == "" || status == "all") { + return BrowseHTMLKey(domain, page) + } + if sort == "" { + sort = "popular" + } + if genre == "" { + genre = "all" + } + if status == "" { + status = "all" + } + return fmt.Sprintf("%s/html/%s-%s-%s/page-%d.html", domain, sort, genre, status, page) +} + +// BrowseCoverKey returns the MinIO object key for a cached book cover image. +// Layout: {domain}/assets/book-covers/{slug}.jpg +func BrowseCoverKey(domain, slug string) string { + return fmt.Sprintf("%s/assets/book-covers/%s.jpg", domain, slug) +} + +// PutBrowsePage stores a SingleFile HTML snapshot in the browse bucket. +func (m *MinioClient) PutBrowsePage(ctx context.Context, key, html string) error { + data := []byte(html) + _, err := m.c.PutObject(ctx, m.cfg.BucketBrowse, key, + bytes.NewReader(data), int64(len(data)), + minio.PutObjectOptions{ContentType: "text/html; charset=utf-8"}) + if err != nil { + return fmt.Errorf("minio: put browse page %s: %w", key, err) + } + return nil +} + +// GetBrowsePage retrieves a SingleFile HTML snapshot from the browse bucket. +// Returns ("", false, nil) when the object does not exist. +func (m *MinioClient) GetBrowsePage(ctx context.Context, key string) (string, bool, error) { + obj, err := m.c.GetObject(ctx, m.cfg.BucketBrowse, key, minio.GetObjectOptions{}) + if err != nil { + return "", false, fmt.Errorf("minio: get browse page %s: %w", key, err) + } + defer obj.Close() + // Check whether the object actually exists by inspecting the Stat. + if _, statErr := obj.Stat(); statErr != nil { + return "", false, nil // not found + } + data, err := io.ReadAll(obj) + if err != nil { + return "", false, fmt.Errorf("minio: read browse page %s: %w", key, err) + } + return string(data), true, nil +} + +// BrowsePageExists returns true if a snapshot object is present in the browse bucket. +func (m *MinioClient) BrowsePageExists(ctx context.Context, key string) bool { + _, err := m.c.StatObject(ctx, m.cfg.BucketBrowse, key, minio.StatObjectOptions{}) + return err == nil +} + +// PutBrowseAsset stores a binary asset (e.g. a cover image) in the browse bucket. +// contentType should be the MIME type, e.g. "image/jpeg". +func (m *MinioClient) PutBrowseAsset(ctx context.Context, key string, data []byte, contentType string) error { + _, err := m.c.PutObject(ctx, m.cfg.BucketBrowse, key, + bytes.NewReader(data), int64(len(data)), + minio.PutObjectOptions{ContentType: contentType}) + if err != nil { + return fmt.Errorf("minio: put browse asset %s: %w", key, err) + } + return nil +} + +// GetBrowseAsset retrieves a binary asset from the browse bucket. +// Returns (nil, false, nil) when the object does not exist. +func (m *MinioClient) GetBrowseAsset(ctx context.Context, key string) ([]byte, string, bool, error) { + obj, err := m.c.GetObject(ctx, m.cfg.BucketBrowse, key, minio.GetObjectOptions{}) + if err != nil { + return nil, "", false, fmt.Errorf("minio: get browse asset %s: %w", key, err) + } + defer obj.Close() + info, statErr := obj.Stat() + if statErr != nil { + return nil, "", false, nil // not found + } + data, err := io.ReadAll(obj) + if err != nil { + return nil, "", false, fmt.Errorf("minio: read browse asset %s: %w", key, err) + } + return data, info.ContentType, true, nil +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +// sanitiseVoice converts a voice name to a filename-safe string. +func sanitiseVoice(voice string) string { + return strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '_' || r == '-' { + return r + } + return '_' + }, voice) +} + +// ─── Avatar objects ─────────────────────────────────────────────────────────── + +// avatarKey returns the MinIO object key for a user avatar. +// Layout: avatars/{userId}.{ext} +func avatarKey(userID, ext string) string { + return fmt.Sprintf("avatars/%s.%s", userID, ext) +} + +// PutAvatar stores an avatar image in the avatars bucket. +// ext should be "jpg", "png", or "webp". +func (m *MinioClient) PutAvatar(ctx context.Context, userID, ext string, data []byte, contentType string) error { + if m.cfg.BucketAvatars == "" { + return fmt.Errorf("minio: avatars bucket not configured") + } + key := avatarKey(userID, ext) + _, err := m.c.PutObject(ctx, m.cfg.BucketAvatars, key, + bytes.NewReader(data), int64(len(data)), + minio.PutObjectOptions{ContentType: contentType}) + if err != nil { + return fmt.Errorf("minio: put avatar %s: %w", key, err) + } + return nil +} + +// PresignAvatarUploadURL returns a presigned PUT URL for uploading an avatar image +// directly to MinIO from the client. Signed with the public endpoint so iOS/browser +// can PUT bytes straight to MinIO without routing through the server. +// ext should be "jpg", "png", or "webp". Expires in 15 minutes. +func (m *MinioClient) PresignAvatarUploadURL(ctx context.Context, userID, ext string) (string, string, error) { + if m.cfg.BucketAvatars == "" { + return "", "", fmt.Errorf("minio: avatars bucket not configured") + } + key := avatarKey(userID, ext) + u, err := m.pub.PresignedPutObject(ctx, m.cfg.BucketAvatars, key, 15*time.Minute) + if err != nil { + return "", "", fmt.Errorf("minio: presign avatar upload %s: %w", key, err) + } + return u.String(), key, nil +} + +// PresignAvatarURL returns a presigned GET URL for a user avatar. +// Returns ("", false, nil) when no avatar exists for the given userID. +func (m *MinioClient) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) { + if m.cfg.BucketAvatars == "" { + return "", false, nil + } + // Try common extensions in order of preference. + for _, ext := range []string{"jpg", "png", "webp", "gif"} { + key := avatarKey(userID, ext) + _, statErr := m.c.StatObject(ctx, m.cfg.BucketAvatars, key, minio.StatObjectOptions{}) + if statErr != nil { + continue + } + u, err := m.pub.PresignedGetObject(ctx, m.cfg.BucketAvatars, key, 24*time.Hour, nil) + if err != nil { + return "", false, fmt.Errorf("minio: presign avatar %s: %w", key, err) + } + return u.String(), true, nil + } + return "", false, nil +} + +// DeleteAvatar removes any existing avatar for the given userID (all extensions). +func (m *MinioClient) DeleteAvatar(ctx context.Context, userID string) error { + if m.cfg.BucketAvatars == "" { + return nil + } + for _, ext := range []string{"jpg", "png", "webp", "gif"} { + key := avatarKey(userID, ext) + _ = m.c.RemoveObject(ctx, m.cfg.BucketAvatars, key, minio.RemoveObjectOptions{}) + } + return nil +} diff --git a/scraper/internal/storage/pocketbase.go b/scraper/internal/storage/pocketbase.go new file mode 100644 index 0000000..94cf579 --- /dev/null +++ b/scraper/internal/storage/pocketbase.go @@ -0,0 +1,939 @@ +// Package storage — PocketBase REST client. +// +// Collections expected in PocketBase: +// +// books — slug(text,unique), title, author, cover, status, genres(json), +// summary, total_chapters(number), source_url, ranking(number), updated(date) +// chapters_idx — slug(text), number(number), title, date_label, updated(date) +// ranking — rank(number), slug(text,unique), title, author, cover, status, +// genres(json), source_url, updated(date) +// progress — session_id(text), slug(text), chapter(number), updated(date) +// audio_cache — cache_key(text,unique), filename(text), updated(date) +// app_users — username(text,unique), password_hash(text), role(text), created(date) +// scraping_tasks — id(auto), kind(text), target_url(text), status(text), +// books_found(number), chapters_scraped(number), +// chapters_skipped(number), errors(number), +// started(date), finished(date), error_message(text) +// user_sessions — user_id(text), session_id(text,unique), user_agent(text), +// ip(text), created_at(date), last_seen(date) +// book_comments — slug(text), user_id(text), username(text), body(text), +// upvotes(number), downvotes(number), created(date) +// comment_votes — comment_id(text), user_id(text), session_id(text), vote(text: up|down) +package storage + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/libnovel/scraper/internal/scraper" +) + +// PocketBaseConfig holds PocketBase connection settings. +type PocketBaseConfig struct { + BaseURL string // e.g. "http://pocketbase:8090" + AdminEmail string + AdminPassword string +} + +// pbClient is a minimal PocketBase admin REST client. +type pbClient struct { + cfg PocketBaseConfig + httpClient *http.Client + log *slog.Logger + + tokenMu sync.RWMutex + token string + tokenExp time.Time +} + +// newPBClient creates a new PocketBase client. It does not authenticate yet; +// authentication happens lazily on the first API call. +func newPBClient(cfg PocketBaseConfig, log *slog.Logger) *pbClient { + return &pbClient{ + cfg: cfg, + httpClient: &http.Client{Timeout: 15 * time.Second}, + log: log, + } +} + +// ─── Auth ───────────────────────────────────────────────────────────────────── + +func (p *pbClient) authenticate(ctx context.Context) error { + body, _ := json.Marshal(map[string]string{ + "identity": p.cfg.AdminEmail, + "password": p.cfg.AdminPassword, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + p.cfg.BaseURL+"/api/collections/_superusers/auth-with-password", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := p.httpClient.Do(req) + if err != nil { + return fmt.Errorf("pocketbase: auth: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("pocketbase: auth status %d: %s", resp.StatusCode, b) + } + var result struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("pocketbase: decode auth: %w", err) + } + p.tokenMu.Lock() + p.token = result.Token + p.tokenExp = time.Now().Add(12 * time.Hour) + p.tokenMu.Unlock() + return nil +} + +func (p *pbClient) authToken(ctx context.Context) (string, error) { + p.tokenMu.RLock() + tok, exp := p.token, p.tokenExp + p.tokenMu.RUnlock() + if tok != "" && time.Now().Before(exp) { + return tok, nil + } + if err := p.authenticate(ctx); err != nil { + return "", err + } + p.tokenMu.RLock() + defer p.tokenMu.RUnlock() + return p.token, nil +} + +// ─── Generic CRUD helpers ────────────────────────────────────────────────────── + +func (p *pbClient) do(ctx context.Context, method, path string, body interface{}) (*http.Response, error) { + tok, err := p.authToken(ctx) + if err != nil { + return nil, err + } + + var bodyReader io.Reader + if body != nil { + b, _ := json.Marshal(body) + bodyReader = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+path, bodyReader) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+tok) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + return p.httpClient.Do(req) +} + +// listOne fetches the first matching record from a collection. +func (p *pbClient) listOne(ctx context.Context, collection, filter string) (map[string]interface{}, error) { + q := url.Values{} + q.Set("filter", filter) + q.Set("perPage", "1") + path := fmt.Sprintf("/api/collections/%s/records?%s", collection, q.Encode()) + resp, err := p.do(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("pocketbase: listOne %s: status %d: %s", collection, resp.StatusCode, b) + } + var result struct { + Items []map[string]interface{} `json:"items"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("pocketbase: listOne %s: decode: %w", collection, err) + } + if len(result.Items) == 0 { + return nil, nil + } + return result.Items[0], nil +} + +// listAll returns all records from a collection matching filter by paginating +// through all pages (PocketBase default page size is capped at 500). +func (p *pbClient) listAll(ctx context.Context, collection, filter, sort string) ([]map[string]interface{}, error) { + const perPage = 500 + var all []map[string]interface{} + + for page := 1; ; page++ { + q := url.Values{} + if filter != "" { + q.Set("filter", filter) + } + if sort != "" { + q.Set("sort", sort) + } + q.Set("perPage", fmt.Sprintf("%d", perPage)) + q.Set("page", fmt.Sprintf("%d", page)) + path := fmt.Sprintf("/api/collections/%s/records?%s", collection, q.Encode()) + resp, err := p.do(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("pocketbase: listAll %s: status %d: %s", collection, resp.StatusCode, b) + } + var result struct { + Page int `json:"page"` + TotalPages int `json:"totalPages"` + Items []map[string]interface{} `json:"items"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + resp.Body.Close() + return nil, fmt.Errorf("pocketbase: listAll %s: decode: %w", collection, err) + } + resp.Body.Close() + all = append(all, result.Items...) + if page >= result.TotalPages || len(result.Items) == 0 { + break + } + } + return all, nil +} + +// upsert creates a record; if one matching filter already exists it updates it. +func (p *pbClient) upsert(ctx context.Context, collection, filter string, data map[string]interface{}) error { + existing, err := p.listOne(ctx, collection, filter) + if err != nil { + return err + } + if existing != nil { + id := existing["id"].(string) + resp, err := p.do(ctx, http.MethodPatch, + fmt.Sprintf("/api/collections/%s/records/%s", collection, id), data) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("pocketbase: upsert (patch) %s id=%s: status %d: %s", collection, id, resp.StatusCode, b) + } + return nil + } + resp, err := p.do(ctx, http.MethodPost, + fmt.Sprintf("/api/collections/%s/records", collection), data) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("pocketbase: upsert (create) %s: status %d: %s", collection, resp.StatusCode, b) + } + return nil +} + +// deleteWhere deletes all records matching filter in collection. +func (p *pbClient) deleteWhere(ctx context.Context, collection, filter string) error { + items, err := p.listAll(ctx, collection, filter, "") + if err != nil { + return err + } + for _, item := range items { + id, _ := item["id"].(string) + resp, err := p.do(ctx, http.MethodDelete, + fmt.Sprintf("/api/collections/%s/records/%s", collection, id), nil) + if err != nil { + return err + } + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return fmt.Errorf("pocketbase: deleteWhere %s id=%s: status %d: %s", collection, id, resp.StatusCode, b) + } + resp.Body.Close() + } + return nil +} + +// ─── PocketBaseStore ────────────────────────────────────────────────────────── + +// PocketBaseStore implements the structured-data portion of the Store interface +// backed by PocketBase REST API. +type PocketBaseStore struct { + pb *pbClient + log *slog.Logger +} + +// NewPocketBaseStore returns a connected PocketBaseStore. +func NewPocketBaseStore(cfg PocketBaseConfig, log *slog.Logger) *PocketBaseStore { + return &PocketBaseStore{pb: newPBClient(cfg, log), log: log} +} + +// Ping verifies connectivity by authenticating. +func (s *PocketBaseStore) Ping(ctx context.Context) error { + _, err := s.pb.authToken(ctx) + return err +} + +// ─── Collections schema bootstrap ──────────────────────────────────────────── +// CollectionDef maps a collection name to its fields for auto-creation. + +// EnsureCollections creates missing collections via the PocketBase API. +// Safe to call on every startup — existing collections are skipped. +func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error { + // We just attempt to create each collection; 400/422 errors for "already + // exists" are silently ignored. + // PocketBase v0.22+ uses "fields"; older versions used "schema". + // We use "fields" which is the current API. + collections := []map[string]interface{}{ + { + "name": "books", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "slug", "type": "text", "required": true}, + {"name": "title", "type": "text", "required": true}, + {"name": "author", "type": "text"}, + {"name": "cover", "type": "text"}, + {"name": "status", "type": "text"}, + {"name": "genres", "type": "json"}, + {"name": "summary", "type": "text"}, + {"name": "total_chapters", "type": "number"}, + {"name": "source_url", "type": "text"}, + {"name": "ranking", "type": "number"}, + {"name": "meta_updated", "type": "date"}, + }, + }, + { + "name": "chapters_idx", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "slug", "type": "text", "required": true}, + {"name": "number", "type": "number", "required": true}, + {"name": "title", "type": "text"}, + {"name": "date_label", "type": "text"}, + }, + }, + { + "name": "ranking", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "rank", "type": "number", "required": true}, + {"name": "slug", "type": "text", "required": true}, + {"name": "title", "type": "text"}, + {"name": "author", "type": "text"}, + {"name": "cover", "type": "text"}, + {"name": "status", "type": "text"}, + {"name": "genres", "type": "json"}, + {"name": "source_url", "type": "text"}, + {"name": "updated", "type": "date"}, + }, + }, + { + "name": "progress", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "session_id", "type": "text", "required": true}, + {"name": "user_id", "type": "text"}, + {"name": "slug", "type": "text", "required": true}, + {"name": "chapter", "type": "number"}, + {"name": "updated", "type": "date"}, + }, + }, + { + "name": "audio_cache", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "cache_key", "type": "text", "required": true}, + {"name": "filename", "type": "text"}, + {"name": "updated", "type": "date"}, + }, + }, + { + "name": "app_users", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "username", "type": "text", "required": true}, + {"name": "password_hash", "type": "text", "required": true}, + {"name": "role", "type": "text"}, + {"name": "created", "type": "date"}, + }, + }, + { + "name": "user_library", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "session_id", "type": "text", "required": true}, + {"name": "user_id", "type": "text"}, + {"name": "slug", "type": "text", "required": true}, + {"name": "saved_at", "type": "date"}, + }, + }, + { + "name": "scraping_tasks", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "kind", "type": "text", "required": true}, // "catalogue" | "book" + {"name": "target_url", "type": "text"}, // set for single-book scrapes + {"name": "status", "type": "text", "required": true}, // "running" | "done" | "failed" | "cancelled" + {"name": "books_found", "type": "number"}, + {"name": "chapters_scraped", "type": "number"}, + {"name": "chapters_skipped", "type": "number"}, + {"name": "errors", "type": "number"}, + {"name": "started", "type": "date"}, + {"name": "finished", "type": "date"}, + {"name": "error_message", "type": "text"}, + }, + }, + { + "name": "audio_jobs", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "cache_key", "type": "text", "required": true}, // "slug/chapter/voice" + {"name": "slug", "type": "text", "required": true}, + {"name": "chapter", "type": "number"}, + {"name": "voice", "type": "text"}, + {"name": "status", "type": "text", "required": true}, // "pending" | "generating" | "done" | "failed" + {"name": "error_message", "type": "text"}, + {"name": "started", "type": "date"}, + {"name": "finished", "type": "date"}, + }, + }, + { + "name": "user_sessions", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "user_id", "type": "text", "required": true}, + {"name": "session_id", "type": "text", "required": true}, // random ID embedded in auth token + {"name": "user_agent", "type": "text"}, + {"name": "ip", "type": "text"}, + {"name": "created_at", "type": "date"}, + {"name": "last_seen", "type": "date"}, + }, + }, + { + "name": "book_comments", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "slug", "type": "text", "required": true}, + {"name": "user_id", "type": "text"}, + {"name": "username", "type": "text"}, + {"name": "body", "type": "text", "required": true}, + {"name": "upvotes", "type": "number"}, + {"name": "downvotes", "type": "number"}, + {"name": "created", "type": "date"}, + {"name": "parent_id", "type": "text"}, // empty = top-level; set = reply to that comment ID + }, + }, + { + "name": "comment_votes", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "comment_id", "type": "text", "required": true}, + {"name": "user_id", "type": "text"}, + {"name": "session_id", "type": "text", "required": true}, + {"name": "vote", "type": "text", "required": true}, // "up" | "down" + }, + }, + { + // follower_id follows followee_id + "name": "user_subscriptions", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "follower_id", "type": "text", "required": true}, + {"name": "followee_id", "type": "text", "required": true}, + {"name": "created", "type": "date"}, + }, + }, + } + for _, col := range collections { + name, _ := col["name"].(string) + resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections", col) + if err != nil { + return fmt.Errorf("pocketbase: ensure collection %q: %w", name, err) + } + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK, http.StatusCreated: + s.log.Info("pocketbase: collection created", "collection", name) + case http.StatusBadRequest, http.StatusUnprocessableEntity: + // Already exists or schema mismatch — expected on subsequent startups. + s.log.Debug("pocketbase: collection already exists (skipped)", "collection", name) + default: + s.log.Warn("pocketbase: unexpected status ensuring collection", + "collection", name, "status", resp.StatusCode, "body", string(b)) + } + } + return nil +} + +// ─── Schema migrations ──────────────────────────────────────────────────────── + +// migration describes a single field to guarantee exists in a collection. +type migration struct { + collection string + fieldName string + fieldType string +} + +// migrations is the ordered list of schema changes applied on every startup. +var migrations = []migration{ + // user_id was added to progress after initial deploy. + {"progress", "user_id", "text"}, + // avatar_url stores the MinIO presign path for the user's profile picture. + {"app_users", "avatar_url", "text"}, + // parent_id enables 1-level comment nesting (replies). Empty = top-level comment. + {"book_comments", "parent_id", "text"}, +} + +// EnsureMigrations idempotently adds any fields that are missing from existing +// collections. It fetches the current schema, checks for each field by name, +// and PATCHes the collection only when something is absent. +// Safe to call on every startup — no-ops when schema is already up to date. +func (s *PocketBaseStore) EnsureMigrations(ctx context.Context) error { + for _, m := range migrations { + if err := s.ensureField(ctx, m); err != nil { + return err + } + } + return nil +} + +func (s *PocketBaseStore) ensureField(ctx context.Context, m migration) error { + // Fetch current collection schema. + resp, err := s.pb.do(ctx, http.MethodGet, "/api/collections/"+m.collection, nil) + if err != nil { + return fmt.Errorf("pocketbase: ensureField %s.%s: fetch schema: %w", m.collection, m.fieldName, err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("pocketbase: ensureField %s.%s: fetch schema status %d: %s", m.collection, m.fieldName, resp.StatusCode, body) + } + + var schema struct { + ID string `json:"id"` + Fields []map[string]interface{} `json:"fields"` + } + if err := json.Unmarshal(body, &schema); err != nil { + return fmt.Errorf("pocketbase: ensureField %s.%s: decode schema: %w", m.collection, m.fieldName, err) + } + + // Check if field already exists. + for _, f := range schema.Fields { + if name, _ := f["name"].(string); name == m.fieldName { + s.log.Debug("pocketbase: field already exists, skipping migration", + "collection", m.collection, "field", m.fieldName) + return nil + } + } + + // Append the new field and PATCH the collection. + newFields := append(schema.Fields, map[string]interface{}{ + "name": m.fieldName, + "type": m.fieldType, + }) + patch := map[string]interface{}{"fields": newFields} + patchResp, err := s.pb.do(ctx, http.MethodPatch, "/api/collections/"+schema.ID, patch) + if err != nil { + return fmt.Errorf("pocketbase: ensureField %s.%s: patch: %w", m.collection, m.fieldName, err) + } + defer patchResp.Body.Close() + patchBody, _ := io.ReadAll(patchResp.Body) + if patchResp.StatusCode != http.StatusOK { + return fmt.Errorf("pocketbase: ensureField %s.%s: patch status %d: %s", m.collection, m.fieldName, patchResp.StatusCode, patchBody) + } + s.log.Info("pocketbase: schema migration applied", "collection", m.collection, "field", m.fieldName, "type", m.fieldType) + return nil +} + +// ─── Book metadata ──────────────────────────────────────────────────────────── + +func (s *PocketBaseStore) UpsertBook(ctx context.Context, slug, title, author, cover, status, summary, sourceURL string, genres []string, totalChapters, ranking int) error { + genresJSON, _ := json.Marshal(genres) + return s.pb.upsert(ctx, "books", fmt.Sprintf(`slug="%s"`, pbEsc(slug)), map[string]interface{}{ + "slug": slug, + "title": title, + "author": author, + "cover": cover, + "status": status, + "genres": string(genresJSON), + "summary": summary, + "total_chapters": totalChapters, + "source_url": sourceURL, + "ranking": ranking, + "meta_updated": time.Now().UTC().Format(time.RFC3339), + }) +} + +func (s *PocketBaseStore) GetBook(ctx context.Context, slug string) (map[string]interface{}, bool, error) { + rec, err := s.pb.listOne(ctx, "books", fmt.Sprintf(`slug="%s"`, pbEsc(slug))) + if err != nil { + return nil, false, err + } + if rec == nil { + return nil, false, nil + } + return rec, true, nil +} + +func (s *PocketBaseStore) ListBooks(ctx context.Context) ([]map[string]interface{}, error) { + return s.pb.listAll(ctx, "books", "", "+title") +} + +func (s *PocketBaseStore) BookMetaUpdated(ctx context.Context, slug string) (time.Time, error) { + rec, err := s.pb.listOne(ctx, "books", fmt.Sprintf(`slug="%s"`, pbEsc(slug))) + if err != nil || rec == nil { + return time.Time{}, err + } + if ts, ok := rec["meta_updated"].(string); ok { + t, err := time.Parse(time.RFC3339, ts) + if err == nil { + return t, nil + } + } + return time.Time{}, nil +} + +// ─── Chapter index ──────────────────────────────────────────────────────────── + +func (s *PocketBaseStore) UpsertChapterIdx(ctx context.Context, slug string, number int, title, dateLabel string) error { + return s.pb.upsert(ctx, "chapters_idx", + fmt.Sprintf(`slug="%s"&&number=%d`, pbEsc(slug), number), + map[string]interface{}{ + "slug": slug, + "number": number, + "title": title, + "date_label": dateLabel, + }) +} + +// WriteChapterRefs upserts chapter index rows (number + title) for all refs +// without writing any chapter text. Errors are logged and skipped; the +// operation is best-effort. +func (s *PocketBaseStore) WriteChapterRefs(ctx context.Context, slug string, refs []scraper.ChapterRef) error { + var firstErr error + for _, ref := range refs { + if err := s.UpsertChapterIdx(ctx, slug, ref.Number, ref.Title, ""); err != nil { + s.log.Warn("pocketbase: WriteChapterRefs: upsert failed", + "slug", slug, "chapter", ref.Number, "err", err) + if firstErr == nil { + firstErr = err + } + } + } + return firstErr +} + +func (s *PocketBaseStore) ListChapterIdx(ctx context.Context, slug string) ([]map[string]interface{}, error) { + return s.pb.listAll(ctx, "chapters_idx", + fmt.Sprintf(`slug="%s"`, pbEsc(slug)), "+number") +} + +func (s *PocketBaseStore) CountChapterIdx(ctx context.Context, slug string) int { + rows, err := s.ListChapterIdx(ctx, slug) + if err != nil { + s.log.Warn("pocketbase: CountChapterIdx failed", "slug", slug, "err", err) + return 0 + } + return len(rows) +} + +// ─── Ranking (per-item) ─────────────────────────────────────────────────────── + +func (s *PocketBaseStore) UpsertRankingItem(ctx context.Context, item RankingItem) error { + genresJSON, _ := json.Marshal(item.Genres) + return s.pb.upsert(ctx, "ranking", fmt.Sprintf(`slug="%s"`, pbEsc(item.Slug)), map[string]interface{}{ + "rank": item.Rank, + "slug": item.Slug, + "title": item.Title, + "author": item.Author, + "cover": item.Cover, + "status": item.Status, + "genres": string(genresJSON), + "source_url": item.SourceURL, + "updated": time.Now().UTC().Format(time.RFC3339), + }) +} + +func (s *PocketBaseStore) ListRankingItems(ctx context.Context) ([]RankingItem, error) { + rows, err := s.pb.listAll(ctx, "ranking", "", "+rank") + if err != nil { + return nil, err + } + items := make([]RankingItem, 0, len(rows)) + for _, r := range rows { + item := RankingItem{ + Rank: int(floatVal(r, "rank")), + Slug: strVal(r, "slug"), + Title: strVal(r, "title"), + Author: strVal(r, "author"), + Cover: strVal(r, "cover"), + Status: strVal(r, "status"), + SourceURL: strVal(r, "source_url"), + } + if ts, ok := r["updated"].(string); ok { + item.Updated, _ = time.Parse(time.RFC3339, ts) + } + switch v := r["genres"].(type) { + case string: + _ = json.Unmarshal([]byte(v), &item.Genres) + case []interface{}: + for _, g := range v { + if s, ok := g.(string); ok { + item.Genres = append(item.Genres, s) + } + } + } + items = append(items, item) + } + return items, nil +} + +// RankingLastUpdated returns the most recent Updated time across all ranking rows, +// or the zero time if no rows exist. +func (s *PocketBaseStore) RankingLastUpdated(ctx context.Context) (time.Time, error) { + // listAll with sort "-updated" and perPage=1 is the cheapest approach. + q := url.Values{} + q.Set("sort", "-updated") + q.Set("perPage", "1") + path := fmt.Sprintf("/api/collections/ranking/records?%s", q.Encode()) + resp, err := s.pb.do(ctx, http.MethodGet, path, nil) + if err != nil { + return time.Time{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return time.Time{}, fmt.Errorf("pocketbase: RankingLastUpdated: status %d: %s", resp.StatusCode, b) + } + var result struct { + Items []map[string]interface{} `json:"items"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return time.Time{}, fmt.Errorf("pocketbase: RankingLastUpdated: decode: %w", err) + } + if len(result.Items) == 0 { + return time.Time{}, nil + } + ts, _ := result.Items[0]["updated"].(string) + t, _ := time.Parse(time.RFC3339, ts) + return t, nil +} + +// ─── Reading progress ───────────────────────────────────────────────────────── + +func (s *PocketBaseStore) SetProgress(ctx context.Context, sessionID, slug string, chapter int) error { + return s.pb.upsert(ctx, "progress", + fmt.Sprintf(`session_id="%s"&&slug="%s"`, pbEsc(sessionID), pbEsc(slug)), + map[string]interface{}{ + "session_id": sessionID, + "slug": slug, + "chapter": chapter, + "updated": time.Now().UTC().Format(time.RFC3339), + }) +} + +func (s *PocketBaseStore) GetProgress(ctx context.Context, sessionID, slug string) (int, time.Time, bool, error) { + rec, err := s.pb.listOne(ctx, "progress", + fmt.Sprintf(`session_id="%s"&&slug="%s"`, pbEsc(sessionID), pbEsc(slug))) + if err != nil { + return 0, time.Time{}, false, err + } + if rec == nil { + return 0, time.Time{}, false, nil + } + ch := int(floatVal(rec, "chapter")) + var updated time.Time + if ts, ok := rec["updated"].(string); ok { + updated, _ = time.Parse(time.RFC3339, ts) + } + return ch, updated, true, nil +} + +func (s *PocketBaseStore) AllProgress(ctx context.Context, sessionID string) ([]map[string]interface{}, error) { + return s.pb.listAll(ctx, "progress", + fmt.Sprintf(`session_id="%s"`, pbEsc(sessionID)), "-updated") +} + +func (s *PocketBaseStore) DeleteProgress(ctx context.Context, sessionID, slug string) error { + return s.pb.deleteWhere(ctx, "progress", + fmt.Sprintf(`session_id="%s"&&slug="%s"`, pbEsc(sessionID), pbEsc(slug))) +} + +// ─── Audio cache ────────────────────────────────────────────────────────────── + +func (s *PocketBaseStore) SetAudioCache(ctx context.Context, cacheKey, filename string) error { + return s.pb.upsert(ctx, "audio_cache", + fmt.Sprintf(`cache_key="%s"`, pbEsc(cacheKey)), + map[string]interface{}{ + "cache_key": cacheKey, + "filename": filename, + "updated": time.Now().UTC().Format(time.RFC3339), + }) +} + +func (s *PocketBaseStore) GetAudioCache(ctx context.Context, cacheKey string) (string, bool, error) { + rec, err := s.pb.listOne(ctx, "audio_cache", + fmt.Sprintf(`cache_key="%s"`, pbEsc(cacheKey))) + if err != nil { + return "", false, err + } + if rec == nil { + return "", false, nil + } + filename, _ := rec["filename"].(string) + return filename, filename != "", nil +} + +// ─── Scraping tasks ─────────────────────────────────────────────────────────── + +// CreateScrapingTask inserts a new scraping_tasks record with status="running" +// and returns the newly created record's ID. +func (s *PocketBaseStore) CreateScrapingTask(ctx context.Context, kind, targetURL string) (string, error) { + data := map[string]interface{}{ + "kind": kind, + "target_url": targetURL, + "status": "running", + "books_found": 0, + "chapters_scraped": 0, + "chapters_skipped": 0, + "errors": 0, + "started": time.Now().UTC().Format(time.RFC3339), + } + resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections/scraping_tasks/records", data) + if err != nil { + return "", err + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("pocketbase: CreateScrapingTask: status %d: %s", resp.StatusCode, b) + } + var rec map[string]interface{} + if err := json.Unmarshal(b, &rec); err != nil { + return "", fmt.Errorf("pocketbase: CreateScrapingTask: decode: %w", err) + } + id, _ := rec["id"].(string) + return id, nil +} + +// UpdateScrapingTask patches counters on an existing scraping_tasks record. +func (s *PocketBaseStore) UpdateScrapingTask(ctx context.Context, id string, data map[string]interface{}) error { + resp, err := s.pb.do(ctx, http.MethodPatch, + fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), data) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("pocketbase: UpdateScrapingTask id=%s: status %d: %s", id, resp.StatusCode, b) + } + return nil +} + +// ListScrapingTasks returns all scraping_tasks sorted by started descending. +func (s *PocketBaseStore) ListScrapingTasks(ctx context.Context) ([]map[string]interface{}, error) { + return s.pb.listAll(ctx, "scraping_tasks", "", "-started") +} + +// ─── Audio jobs ─────────────────────────────────────────────────────────────── + +// CreateAudioJob inserts a new audio_jobs record with status="pending". +func (s *PocketBaseStore) CreateAudioJob(ctx context.Context, slug string, chapter int, voice string) (string, error) { + cacheKey := fmt.Sprintf("%s/%d/%s", slug, chapter, voice) + data := map[string]interface{}{ + "cache_key": cacheKey, + "slug": slug, + "chapter": chapter, + "voice": voice, + "status": "pending", + "started": time.Now().UTC().Format(time.RFC3339), + } + resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections/audio_jobs/records", data) + if err != nil { + return "", err + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("pocketbase: CreateAudioJob: status %d: %s", resp.StatusCode, b) + } + var rec map[string]interface{} + if err := json.Unmarshal(b, &rec); err != nil { + return "", fmt.Errorf("pocketbase: CreateAudioJob: decode: %w", err) + } + id, _ := rec["id"].(string) + return id, nil +} + +// UpdateAudioJob patches status, error_message, and optionally finished on an audio_jobs record. +func (s *PocketBaseStore) UpdateAudioJob(ctx context.Context, id, status, errMsg string, finished time.Time) error { + data := map[string]interface{}{ + "status": status, + "error_message": errMsg, + } + if !finished.IsZero() { + data["finished"] = finished.UTC().Format(time.RFC3339) + } + resp, err := s.pb.do(ctx, http.MethodPatch, + fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), data) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("pocketbase: UpdateAudioJob id=%s: status %d: %s", id, resp.StatusCode, b) + } + return nil +} + +// GetAudioJob returns the most recent audio_jobs record for the given cache key. +func (s *PocketBaseStore) GetAudioJob(ctx context.Context, cacheKey string) (map[string]interface{}, bool, error) { + rec, err := s.pb.listOne(ctx, "audio_jobs", + fmt.Sprintf(`cache_key="%s"`, pbEsc(cacheKey))) + if err != nil { + return nil, false, err + } + if rec == nil { + return nil, false, nil + } + return rec, true, nil +} + +// ListAudioJobs returns all audio_jobs sorted by started descending. +func (s *PocketBaseStore) ListAudioJobs(ctx context.Context) ([]map[string]interface{}, error) { + return s.pb.listAll(ctx, "audio_jobs", "", "-started") +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +// pbEsc escapes a string for use in a PocketBase filter expression. +// Only escapes double-quotes to prevent injection. +func pbEsc(s string) string { + return strings.ReplaceAll(s, `"`, `\"`) +} + +func floatVal(m map[string]interface{}, key string) float64 { + if v, ok := m[key].(float64); ok { + return v + } + return 0 +} diff --git a/scraper/internal/storage/scrape_integration_test.go b/scraper/internal/storage/scrape_integration_test.go new file mode 100644 index 0000000..515875a --- /dev/null +++ b/scraper/internal/storage/scrape_integration_test.go @@ -0,0 +1,203 @@ +//go:build integration + +// Integration tests that combine live scraping (Browserless) with real storage +// (MinIO + PocketBase) via HybridStore. +// +// These tests require ALL THREE services to be running. They are gated behind +// the "integration" build tag and skipped when any service URL is missing. +// +// Run with: +// +// BROWSERLESS_URL=http://localhost:3030 \ +// MINIO_ENDPOINT=localhost:9000 \ +// POCKETBASE_URL=http://localhost:8090 \ +// go test -v -tags integration -timeout 600s \ +// github.com/libnovel/scraper/internal/storage +package storage + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "testing" + "time" + + "github.com/libnovel/scraper/internal/browser" + "github.com/libnovel/scraper/internal/novelfire" + "github.com/libnovel/scraper/internal/scraper" +) + +const ( + scrapeTestBookURL = "https://novelfire.net/book/a-dragon-against-the-whole-world" + scrapeTestBookSlug = "a-dragon-against-the-whole-world" +) + +// newScrapeAndStoreFixture builds a novelfire Scraper and a HybridStore, +// skipping the test if any required env var is absent. +func newScrapeAndStoreFixture(t *testing.T) (*novelfire.Scraper, *HybridStore) { + t.Helper() + + browserlessURL := os.Getenv("BROWSERLESS_URL") + if browserlessURL == "" { + t.Skip("BROWSERLESS_URL not set — skipping scrape+store integration test") + } + if os.Getenv("MINIO_ENDPOINT") == "" { + t.Skip("MINIO_ENDPOINT not set — skipping scrape+store integration test") + } + if os.Getenv("POCKETBASE_URL") == "" { + t.Skip("POCKETBASE_URL not set — skipping scrape+store integration test") + } + + client := browser.NewContentClient(browser.Config{ + BaseURL: browserlessURL, + Token: os.Getenv("BROWSERLESS_TOKEN"), + Timeout: 120 * time.Second, + MaxConcurrent: 1, + }) + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) + sc := novelfire.New(client, log, client, nil, nil) + hs := newTestHybridStore(t) + return sc, hs +} + +// TestScrapeAndStore_BookMetadata scrapes the test book's metadata and stores +// it via HybridStore.WriteMetadata, then verifies a ReadMetadata round-trip. +func TestScrapeAndStore_BookMetadata(t *testing.T) { + sc, hs := newScrapeAndStoreFixture(t) + + slug := scrapeTestBookSlug + "-scrapetest" + t.Cleanup(func() { + cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = hs.pb.pb.deleteWhere(cleanCtx, "books", fmt.Sprintf(`slug="%s"`, slug)) + }) + + // 1. Scrape metadata from the live site. + scrapeCtx, scrapeCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer scrapeCancel() + + meta, err := sc.ScrapeMetadata(scrapeCtx, scrapeTestBookURL) + if err != nil { + t.Fatalf("ScrapeMetadata: %v", err) + } + t.Logf("scraped: slug=%q title=%q author=%q totalChapters=%d", + meta.Slug, meta.Title, meta.Author, meta.TotalChapters) + + // Override slug with our test-specific value to avoid polluting real data. + meta.Slug = slug + + // 2. Write to HybridStore. + storeCtx, storeCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer storeCancel() + + if err := hs.WriteMetadata(storeCtx, meta); err != nil { + t.Fatalf("WriteMetadata: %v", err) + } + + // 3. Read back and verify. + got, found, err := hs.ReadMetadata(storeCtx, slug) + if err != nil { + t.Fatalf("ReadMetadata: %v", err) + } + if !found { + t.Fatal("ReadMetadata: not found after WriteMetadata") + } + + t.Logf("read back: title=%q author=%q totalChapters=%d", got.Title, got.Author, got.TotalChapters) + + if got.Title == "" { + t.Error("Title is empty after round-trip") + } + if got.Author == "" { + t.Error("Author is empty after round-trip") + } + if got.TotalChapters < 1 { + t.Errorf("TotalChapters = %d, want >= 1", got.TotalChapters) + } +} + +// TestScrapeAndStore_First3Chapters scrapes chapters 1, 2, and 3 from the +// live site and stores each via HybridStore.WriteChapter, then verifies +// ReadChapter returns non-empty markdown with the expected header. +func TestScrapeAndStore_First3Chapters(t *testing.T) { + sc, hs := newScrapeAndStoreFixture(t) + + // Use a unique test slug so we don't pollute the real book. + slug := fmt.Sprintf("%s-chtest-%d", scrapeTestBookSlug, time.Now().UnixMilli()%100000) + + t.Cleanup(func() { + cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = hs.pb.pb.deleteWhere(cleanCtx, "chapters_idx", fmt.Sprintf(`slug="%s"`, slug)) + }) + + // Pre-build chapter refs (known URLs for this test book). + refs := []scraper.ChapterRef{ + {Number: 1, Title: "Chapter 1", Volume: 0, URL: scrapeTestBookURL + "/chapter-1"}, + {Number: 2, Title: "Chapter 2", Volume: 0, URL: scrapeTestBookURL + "/chapter-2"}, + {Number: 3, Title: "Chapter 3", Volume: 0, URL: scrapeTestBookURL + "/chapter-3"}, + } + + for _, ref := range refs { + ref := ref // capture loop variable + t.Run(fmt.Sprintf("chapter-%d", ref.Number), func(t *testing.T) { + // 1. Scrape chapter text. + scrapeCtx, scrapeCancel := context.WithTimeout(context.Background(), 120*time.Second) + defer scrapeCancel() + + ch, err := sc.ScrapeChapterText(scrapeCtx, ref) + if err != nil { + t.Fatalf("ScrapeChapterText(%d): %v", ref.Number, err) + } + t.Logf("scraped chapter %d: %d bytes of markdown", ref.Number, len(ch.Text)) + + if len(ch.Text) < 100 { + t.Errorf("scraped text too short (%d bytes)", len(ch.Text)) + } + + // 2. Write to HybridStore. + storeCtx, storeCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer storeCancel() + + if err := hs.WriteChapter(storeCtx, slug, ch); err != nil { + t.Fatalf("WriteChapter(%d): %v", ref.Number, err) + } + + // 3. Read back and verify. + got, err := hs.ReadChapter(storeCtx, slug, ref.Number) + if err != nil { + t.Fatalf("ReadChapter(%d): %v", ref.Number, err) + } + if got == "" { + t.Fatalf("ReadChapter(%d): returned empty string", ref.Number) + } + if len(got) < 100 { + t.Errorf("ReadChapter(%d): content too short (%d bytes)", ref.Number, len(got)) + } + + // WriteChapter prepends "# <title>\n\n". + if !strings.HasPrefix(got, "# ") { + t.Errorf("chapter %d: stored content does not start with markdown header: %q", + ref.Number, got[:min(len(got), 60)]) + } + + // Verify the original scraped text body is present. + if !strings.Contains(got, ch.Text[:min(len(ch.Text), 50)]) { + t.Errorf("chapter %d: stored content does not contain scraped text excerpt", ref.Number) + } + + t.Logf("chapter %d stored and verified: %d bytes", ref.Number, len(got)) + }) + } + + // After all chapters written, verify count. + countCtx, countCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer countCancel() + + count := hs.CountChapters(countCtx, slug) + if count != len(refs) { + t.Errorf("CountChapters = %d, want %d", count, len(refs)) + } +} diff --git a/scraper/internal/storage/store.go b/scraper/internal/storage/store.go new file mode 100644 index 0000000..6dfb286 --- /dev/null +++ b/scraper/internal/storage/store.go @@ -0,0 +1,218 @@ +// Package storage defines the unified Store interface and helper types used by +// the server and orchestrator. Concrete implementations back the interface +// with PocketBase (structured data) and MinIO (binary objects). +package storage + +import ( + "context" + "time" + + "github.com/libnovel/scraper/internal/scraper" +) + +// ─── Shared types ───────────────────────────────────────────────────────────── + +// ChapterInfo is a lightweight chapter descriptor (mirrors writer.ChapterInfo). +type ChapterInfo struct { + Number int + Title string + Date string +} + +// RankingItem represents a single entry in the novel ranking list. +// Aliased from scraper.RankingItem for convenience within this package. +type RankingItem = scraper.RankingItem + +// ReadingProgress holds a single user's reading position for one book. +type ReadingProgress struct { + Slug string `json:"slug"` + Chapter int `json:"chapter"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AudioJob represents a single audio-generation job record from the +// audio_jobs collection. +type AudioJob struct { + ID string `json:"id"` + CacheKey string `json:"cache_key"` // "slug/chapter/voice" + Slug string `json:"slug"` + Chapter int `json:"chapter"` + Voice string `json:"voice"` + Status string `json:"status"` // "pending" | "generating" | "done" | "failed" + ErrorMessage string `json:"error_message,omitempty"` + Started time.Time `json:"started"` + Finished time.Time `json:"finished,omitempty"` +} + +// ScrapeTask represents a single scraping job record from the scraping_tasks +// collection. +type ScrapeTask struct { + ID string `json:"id"` + Kind string `json:"kind"` // "catalogue" | "book" + TargetURL string `json:"target_url"` // non-empty for single-book scrapes + Status string `json:"status"` // "running" | "done" | "failed" | "cancelled" + BooksFound int `json:"books_found"` + ChaptersScraped int `json:"chapters_scraped"` + ChaptersSkipped int `json:"chapters_skipped"` + Errors int `json:"errors"` + Started time.Time `json:"started"` + Finished time.Time `json:"finished,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +// ScrapeTaskUpdate carries the fields that can be patched on a ScrapeTask. +// Zero-value fields are still sent; callers should only include keys they want +// to change via the map form used inside the store implementation. +type ScrapeTaskUpdate struct { + Status string + BooksFound int + ChaptersScraped int + ChaptersSkipped int + Errors int + Finished time.Time // zero = not finished yet + ErrorMessage string +} + +// ─── Store interface ────────────────────────────────────────────────────────── + +// Store is the single persistence abstraction consumed by the server and the +// orchestrator. Implementations may route calls to different backends +// (PocketBase for structured records, MinIO for binary blobs). +type Store interface { + // ── Book metadata ────────────────────────────────────────────────────── + + // WriteMetadata upserts book metadata. + WriteMetadata(ctx context.Context, meta scraper.BookMeta) error + // ReadMetadata returns the metadata for slug. Returns (zero, false, nil) + // when the book is not found. + ReadMetadata(ctx context.Context, slug string) (scraper.BookMeta, bool, error) + // ListBooks returns all books, sorted alphabetically by title. + ListBooks(ctx context.Context) ([]scraper.BookMeta, error) + // LocalSlugs returns the set of slugs that have metadata stored. + LocalSlugs(ctx context.Context) (map[string]bool, error) + // MetadataMtime returns the Unix-second mtime of the metadata record, or 0. + MetadataMtime(ctx context.Context, slug string) int64 + + // ── Chapters (binary blobs in MinIO) ─────────────────────────────────── + + // ChapterExists returns true if the markdown file for the given ref exists. + ChapterExists(ctx context.Context, slug string, ref scraper.ChapterRef) bool + // WriteChapter stores the chapter markdown. + WriteChapter(ctx context.Context, slug string, chapter scraper.Chapter) error + // WriteChapterRefs persists chapter metadata (number + title) into the + // chapters_idx table without fetching or storing any chapter text. + // It is used to pre-populate the chapter list when a book is first seen + // via a live preview, before its chapter text has been scraped. + WriteChapterRefs(ctx context.Context, slug string, refs []scraper.ChapterRef) error + // ReadChapter returns the raw markdown for chapter number n. + ReadChapter(ctx context.Context, slug string, n int) (string, error) + // ListChapters returns all stored chapters for slug, sorted by number. + ListChapters(ctx context.Context, slug string) ([]ChapterInfo, error) + // CountChapters returns the number of stored chapters for slug. + CountChapters(ctx context.Context, slug string) int + // ReindexChapters rebuilds chapters_idx from MinIO objects for slug. + // Returns the number of chapters indexed. + ReindexChapters(ctx context.Context, slug string) (int, error) + + // ── Ranking ──────────────────────────────────────────────────────────── + + // WriteRankingItem upserts a single ranking entry (keyed on Slug). + WriteRankingItem(ctx context.Context, item RankingItem) error + // ReadRankingItems returns all ranking items sorted by rank ascending. + ReadRankingItems(ctx context.Context) ([]RankingItem, error) + // RankingFreshEnough returns true when ranking rows exist and the most + // recent Updated timestamp is within maxAge of now. + RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error) + + // ── Audio cache ──────────────────────────────────────────────────────── + + // GetAudioCache returns the Kokoro filename for cacheKey, or ("", false). + GetAudioCache(ctx context.Context, cacheKey string) (string, bool) + // SetAudioCache persists a Kokoro filename for cacheKey. + SetAudioCache(ctx context.Context, cacheKey, filename string) error + // PutAudio stores raw audio bytes under the given MinIO object key. + PutAudio(ctx context.Context, key string, data []byte) error + + // ── Reading progress ─────────────────────────────────────────────────── + + // GetProgress returns the reading progress for the given session ID and slug. + // Returns (zero, false) if no progress is recorded. + GetProgress(ctx context.Context, sessionID, slug string) (ReadingProgress, bool) + // SetProgress saves or updates reading progress. + SetProgress(ctx context.Context, sessionID string, p ReadingProgress) error + // AllProgress returns all progress entries for a session. + AllProgress(ctx context.Context, sessionID string) ([]ReadingProgress, error) + // DeleteProgress removes progress for a specific slug. + DeleteProgress(ctx context.Context, sessionID, slug string) error + + // ── Audio object paths (MinIO) ───────────────────────────────────────── + + // AudioObjectKey returns the MinIO object key for a cached audio file. + AudioObjectKey(slug string, n int, voice string) string + // AudioExists returns true when the audio object is present in the bucket. + AudioExists(ctx context.Context, key string) bool + + // ── Presigned URLs ───────────────────────────────────────────────────── + + // PresignChapter returns a presigned GET URL for a chapter markdown object. + PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error) + + // PresignAudio returns a presigned GET URL for an audio object. + PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error) + + // PresignAvatarUpload returns a short-lived presigned PUT URL for uploading + // an avatar image directly to MinIO, and the object key that will be stored. + // ext should be "jpg", "png", or "webp". + PresignAvatarUpload(ctx context.Context, userID, ext string) (uploadURL, key string, err error) + + // PresignAvatarURL returns a presigned GET URL for a user's avatar, or ("", false, nil) if none. + PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) + + // DeleteAvatar removes all avatar objects for a user (all extensions). + DeleteAvatar(ctx context.Context, userID string) error + + // ── Browse page snapshots (MinIO) ────────────────────────────────────── + + // SaveBrowsePage stores a SingleFile HTML snapshot for the given cache key. + SaveBrowsePage(ctx context.Context, key, html string) error + // GetBrowsePage retrieves a cached HTML snapshot. Returns ("", false, nil) + // when no snapshot exists for the key. + GetBrowsePage(ctx context.Context, key string) (string, bool, error) + // BrowseHTMLKey returns the MinIO object key for a SingleFile HTML snapshot. + // Layout: {domain}/html/page-{n}.html + BrowseHTMLKey(domain string, page int) string + // BrowseFilteredHTMLKey returns the MinIO object key for a browse page snapshot + // that incorporates sort/genre/status so different filter combos are cached separately. + BrowseFilteredHTMLKey(domain string, page int, sort, genre, status string) string + // BrowseCoverKey returns the MinIO object key for a cached book cover image. + // Layout: {domain}/assets/book-covers/{slug}.jpg + BrowseCoverKey(domain, slug string) string + // SaveBrowseAsset stores a binary asset (e.g. a cover image) in the browse bucket. + SaveBrowseAsset(ctx context.Context, key string, data []byte, contentType string) error + // GetBrowseAsset retrieves a binary asset from the browse bucket. + // Returns (nil, "", false, nil) when the object does not exist. + GetBrowseAsset(ctx context.Context, key string) ([]byte, string, bool, error) + + // ── Scraping tasks ───────────────────────────────────────────────────── + + // CreateScrapeTask inserts a new scraping_tasks record with status="running" + // and returns the assigned ID. + CreateScrapeTask(ctx context.Context, kind, targetURL string) (string, error) + // UpdateScrapeTask patches an existing task record. + UpdateScrapeTask(ctx context.Context, id string, u ScrapeTaskUpdate) error + // ListScrapeTasks returns all tasks sorted by started descending. + ListScrapeTasks(ctx context.Context) ([]ScrapeTask, error) + + // ── Audio jobs ───────────────────────────────────────────────────────── + + // CreateAudioJob inserts a new audio_jobs record with status="pending" + // and returns the assigned ID. + CreateAudioJob(ctx context.Context, slug string, chapter int, voice string) (string, error) + // UpdateAudioJob patches an existing audio job record (status, error, finished). + UpdateAudioJob(ctx context.Context, id, status, errMsg string, finished time.Time) error + // GetAudioJob returns the most recent audio job for the given cache key, + // or (zero, false, nil) if none exists. + GetAudioJob(ctx context.Context, cacheKey string) (AudioJob, bool, error) + // ListAudioJobs returns all audio jobs sorted by started descending. + ListAudioJobs(ctx context.Context) ([]AudioJob, error) +} diff --git a/scraper/internal/writer/writer.go b/scraper/internal/writer/writer.go deleted file mode 100644 index 82023d2..0000000 --- a/scraper/internal/writer/writer.go +++ /dev/null @@ -1,476 +0,0 @@ -// Package writer handles persistence of scraped chapters and metadata. -// -// Directory layout: -// -// static/books/ -// ├── {book-slug}/ -// │ ├── metadata.yaml -// │ ├── vol-0/ (no volume grouping) -// │ │ ├── 1-50/ -// │ │ │ ├── chapter-1.md -// │ │ │ └── … -// │ │ └── 51-100/ -// │ │ └── … -// │ └── vol-1/ -// │ └── … -package writer - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - - "github.com/libnovel/scraper/internal/scraper" - "gopkg.in/yaml.v3" -) - -const chaptersPerFolder = 50 - -// Writer persists scraped content under a configurable root directory. -type Writer struct { - root string // e.g. "./static/books" -} - -// New creates a Writer that stores files under root. -func New(root string) *Writer { - return &Writer{root: root} -} - -// ─── Metadata ───────────────────────────────────────────────────────────────── - -// WriteMetadata serialises meta to static/books/{slug}/metadata.yaml. -// It creates the directory if it does not exist and overwrites any existing file. -func (w *Writer) WriteMetadata(meta scraper.BookMeta) error { - dir := w.bookDir(meta.Slug) - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("writer: mkdir %s: %w", dir, err) - } - - path := filepath.Join(dir, "metadata.yaml") - f, err := os.Create(path) - if err != nil { - return fmt.Errorf("writer: create metadata %s: %w", path, err) - } - defer f.Close() - - enc := yaml.NewEncoder(f) - enc.SetIndent(2) - if err := enc.Encode(meta); err != nil { - return fmt.Errorf("writer: encode metadata: %w", err) - } - return enc.Close() -} - -// ReadMetadata reads the metadata.yaml for slug if it exists. -// Returns (zero-value, false, nil) when the file does not exist. -func (w *Writer) ReadMetadata(slug string) (scraper.BookMeta, bool, error) { - path := filepath.Join(w.bookDir(slug), "metadata.yaml") - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return scraper.BookMeta{}, false, nil - } - return scraper.BookMeta{}, false, fmt.Errorf("writer: read metadata %s: %w", path, err) - } - - var meta scraper.BookMeta - if err := yaml.Unmarshal(data, &meta); err != nil { - return scraper.BookMeta{}, true, fmt.Errorf("writer: unmarshal metadata %s: %w", path, err) - } - return meta, true, nil -} - -// MetadataMtime returns the modification time (Unix seconds) of the -// metadata.yaml file for slug, or 0 if the file cannot be stat'd. -func (w *Writer) MetadataMtime(slug string) int64 { - path := filepath.Join(w.bookDir(slug), "metadata.yaml") - fi, err := os.Stat(path) - if err != nil { - return 0 - } - return fi.ModTime().Unix() -} - -// ─── Chapters ───────────────────────────────────────────────────────────────── - -// ChapterExists returns true if the markdown file for ref already exists on disk. -func (w *Writer) ChapterExists(slug string, ref scraper.ChapterRef) bool { - _, err := os.Stat(w.chapterPath(slug, ref)) - return err == nil -} - -// WriteChapter writes chapter.Text to the appropriate markdown file. -// The parent directories are created on demand. -func (w *Writer) WriteChapter(slug string, chapter scraper.Chapter) error { - path := w.chapterPath(slug, chapter.Ref) - dir := filepath.Dir(path) - - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("writer: mkdir %s: %w", dir, err) - } - - // Build the markdown document. - var sb strings.Builder - sb.WriteString("# ") - sb.WriteString(chapter.Ref.Title) - sb.WriteString("\n\n") - sb.WriteString(chapter.Text) - sb.WriteString("\n") - - if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil { - return fmt.Errorf("writer: write chapter %s: %w", path, err) - } - return nil -} - -// ─── Catalogue helpers ──────────────────────────────────────────────────────── - -// ListBooks returns metadata for every book that has a metadata.yaml under root. -// Books with unreadable metadata files are silently skipped. -func (w *Writer) ListBooks() ([]scraper.BookMeta, error) { - entries, err := os.ReadDir(w.root) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("writer: list books: %w", err) - } - var books []scraper.BookMeta - for _, e := range entries { - if !e.IsDir() { - continue - } - meta, ok, _ := w.ReadMetadata(e.Name()) - if !ok { - continue - } - books = append(books, meta) - } - sort.Slice(books, func(i, j int) bool { - return books[i].Title < books[j].Title - }) - return books, nil -} - -// LocalSlugs returns the set of book slugs that have a metadata.yaml on disk. -// It is cheaper than ListBooks because it only checks for file existence rather -// than fully parsing every YAML file. -func (w *Writer) LocalSlugs() map[string]bool { - entries, err := os.ReadDir(w.root) - if err != nil { - return map[string]bool{} - } - slugs := make(map[string]bool, len(entries)) - for _, e := range entries { - if !e.IsDir() { - continue - } - metaPath := filepath.Join(w.root, e.Name(), "metadata.yaml") - if _, err := os.Stat(metaPath); err == nil { - slugs[e.Name()] = true - } - } - return slugs -} - -// ChapterInfo is a lightweight chapter descriptor derived from on-disk files. -type ChapterInfo struct { - Number int - Title string // chapter name, cleaned of number prefix and trailing date - Date string // relative date scraped alongside the title, e.g. "1 year ago" -} - -// ListChapters returns all chapters on disk for slug, sorted by number. -func (w *Writer) ListChapters(slug string) ([]ChapterInfo, error) { - bookDir := w.bookDir(slug) - var chapters []ChapterInfo - - // Walk vol-*/range-*/ directories. - volDirs, err := filepath.Glob(filepath.Join(bookDir, "vol-*")) - if err != nil { - return nil, fmt.Errorf("writer: list chapters glob: %w", err) - } - for _, vd := range volDirs { - rangeDirs, _ := filepath.Glob(filepath.Join(vd, "*-*")) - for _, rd := range rangeDirs { - files, _ := filepath.Glob(filepath.Join(rd, "chapter-*.md")) - for _, f := range files { - base := filepath.Base(f) // chapter-N.md - numStr := strings.TrimSuffix(strings.TrimPrefix(base, "chapter-"), ".md") - n, err := strconv.Atoi(numStr) - if err != nil { - continue - } - title, date := chapterTitle(f, n) - chapters = append(chapters, ChapterInfo{Number: n, Title: title, Date: date}) - } - } - } - sort.Slice(chapters, func(i, j int) bool { - return chapters[i].Number < chapters[j].Number - }) - return chapters, nil -} - -// CountChapters returns the number of chapter markdown files on disk for slug. -// It is cheaper than ListChapters because it does not read file contents. -func (w *Writer) CountChapters(slug string) int { - bookDir := w.bookDir(slug) - volDirs, err := filepath.Glob(filepath.Join(bookDir, "vol-*")) - if err != nil { - return 0 - } - count := 0 - for _, vd := range volDirs { - rangeDirs, _ := filepath.Glob(filepath.Join(vd, "*-*")) - for _, rd := range rangeDirs { - files, _ := filepath.Glob(filepath.Join(rd, "chapter-*.md")) - count += len(files) - } - } - return count -} - -// chapterTitle reads the first non-empty line of a markdown file and strips -// the leading "# " heading marker. Falls back to "Chapter N". -func chapterTitle(path string, n int) (title, date string) { - data, err := os.ReadFile(path) - if err != nil { - return fmt.Sprintf("Chapter %d", n), "" - } - for _, line := range strings.SplitN(string(data), "\n", 10) { - line = strings.TrimSpace(line) - if line == "" { - continue - } - line = strings.TrimPrefix(line, "# ") - return SplitChapterTitle(line) - } - return fmt.Sprintf("Chapter %d", n), "" -} - -// SplitChapterTitle separates the human-readable chapter name from the -// trailing relative-date string that novelfire.net appends to the heading. -// Examples of raw heading text (after stripping "# "): -// -// "1 Chapter 1 - 1: The Academy's Weakest1 year ago" -// "2 Chapter 2 - Enter the Storm3 months ago" -// -// The pattern is: optional leading number+whitespace, then the real title, -// then a date that matches /\d+\s+(second|minute|hour|day|week|month|year)s?\s+ago$/ -func SplitChapterTitle(raw string) (title, date string) { - // Strip a leading chapter-number index that novelfire sometimes prepends. - // It looks like "1 " or "12 " at the very start. - raw = strings.TrimSpace(raw) - if idx := strings.IndexFunc(raw, func(r rune) bool { return r == ' ' || r == '\t' }); idx > 0 { - prefix := raw[:idx] - allDigit := true - for _, c := range prefix { - if c < '0' || c > '9' { - allDigit = false - break - } - } - if allDigit { - raw = strings.TrimSpace(raw[idx:]) - } - } - - // Strip "Chapter N - N: " prefix (novelfire double-number format). - // Also handles "Chapter N: " (single number) and "Chapter N - Title" without colon. - chNumRe := regexp.MustCompile(`(?i)^chapter\s+\d+(?:\s*-\s*\d+)?\s*:\s*`) - raw = strings.TrimSpace(chNumRe.ReplaceAllString(raw, "")) - - // Match a trailing relative date: "<n> <unit>[s] ago" - dateRe := regexp.MustCompile(`\s*(\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago)\s*$`) - if m := dateRe.FindStringSubmatchIndex(raw); m != nil { - return strings.TrimSpace(raw[:m[0]]), strings.TrimSpace(raw[m[2]:m[3]]) - } - return raw, "" -} - -// ReadChapter returns the raw markdown content for chapter number n of slug. -func (w *Writer) ReadChapter(slug string, n int) (string, error) { - // Reconstruct path using the same bucketing formula as chapterPath. - ref := scraper.ChapterRef{Number: n, Volume: 0} - path := w.chapterPath(slug, ref) - data, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("writer: read chapter %d: %w", n, err) - } - return string(data), nil -} - -// ─── Ranking ───────────────────────────────────────────────────────────────── - -// RankingItem represents a single entry in the ranking. -type RankingItem struct { - Rank int `yaml:"rank" json:"rank"` - Slug string `yaml:"slug" json:"slug"` - Title string `yaml:"title" json:"title"` - Author string `yaml:"author,omitempty" json:"author,omitempty"` - Cover string `yaml:"cover,omitempty" json:"cover,omitempty"` - Status string `yaml:"status,omitempty" json:"status,omitempty"` - Genres []string `yaml:"genres,omitempty" json:"genres,omitempty"` - SourceURL string `yaml:"source_url,omitempty" json:"source_url,omitempty"` -} - -// WriteRanking saves the ranking items as JSON to static/books/ranking.json. -// This replaces the old markdown table format with a structured format that -// is faster to read back (no custom parsing) and safe for titles containing "|". -func (w *Writer) WriteRanking(items []RankingItem) error { - path := filepath.Clean(w.rankingPath()) - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("writer: mkdir %s: %w", dir, err) - } - - data, err := json.MarshalIndent(items, "", " ") - if err != nil { - return fmt.Errorf("writer: marshal ranking: %w", err) - } - if err := os.WriteFile(path, data, 0o644); err != nil { - return fmt.Errorf("writer: write ranking %s: %w", path, err) - } - return nil -} - -// ReadRankingItems parses ranking.json into a slice of RankingItem. -// Returns nil slice (not an error) when the file does not exist yet. -func (w *Writer) ReadRankingItems() ([]RankingItem, error) { - data, err := os.ReadFile(w.rankingPath()) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("writer: read ranking: %w", err) - } - var items []RankingItem - if err := json.Unmarshal(data, &items); err != nil { - return nil, fmt.Errorf("writer: parse ranking json: %w", err) - } - return items, nil -} - -// RankingFileInfo returns os.FileInfo for the ranking.json file, if it exists. -func (w *Writer) RankingFileInfo() (os.FileInfo, error) { - return os.Stat(w.rankingPath()) -} - -func (w *Writer) rankingPath() string { - return filepath.Join(w.root, "ranking.json") -} - -// ─── Ranking page HTML cache ────────────────────────────────────────────────── - -// rankingCacheDir returns the directory that stores per-page HTML caches. -func (w *Writer) rankingCacheDir() string { - return filepath.Join(w.root, "_ranking_cache") -} - -// rankingPageCachePath returns the path for a cached ranking page HTML file. -func (w *Writer) rankingPageCachePath(page int) string { - return filepath.Join(w.rankingCacheDir(), fmt.Sprintf("page-%d.html", page)) -} - -// WriteRankingPageCache persists raw HTML for the given ranking page number. -func (w *Writer) WriteRankingPageCache(page int, html string) error { - dir := w.rankingCacheDir() - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("writer: mkdir ranking cache %s: %w", dir, err) - } - path := w.rankingPageCachePath(page) - if err := os.WriteFile(path, []byte(html), 0o644); err != nil { - return fmt.Errorf("writer: write ranking page cache %s: %w", path, err) - } - return nil -} - -// ReadRankingPageCache reads the cached HTML for the given ranking page. -// Returns ("", nil) when no cache file exists yet. -func (w *Writer) ReadRankingPageCache(page int) (string, error) { - data, err := os.ReadFile(w.rankingPageCachePath(page)) - if err != nil { - if os.IsNotExist(err) { - return "", nil - } - return "", fmt.Errorf("writer: read ranking page cache page %d: %w", page, err) - } - return string(data), nil -} - -// RankingPageCacheInfo returns os.FileInfo for a cached ranking page file. -// Returns (nil, nil) when the file does not exist. -func (w *Writer) RankingPageCacheInfo(page int) (os.FileInfo, error) { - info, err := os.Stat(w.rankingPageCachePath(page)) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - return info, nil -} - -// bookDir returns the root directory for a book slug. -func (w *Writer) bookDir(slug string) string { - return filepath.Join(w.root, slug) -} - -// AudioDir returns the directory used to cache generated MP3 files for a book. -func (w *Writer) AudioDir(slug string) string { - return filepath.Join(w.bookDir(slug), "audio") -} - -// AudioPath returns the full path for a cached chapter audio file. -// The filename is keyed by chapter number, voice, and speed so that different -// settings never collide. Speed is formatted to one decimal place (e.g. "1.0"). -func (w *Writer) AudioPath(slug string, n int, voice string, speed float64) string { - safeVoice := sanitiseVoice(voice) - filename := fmt.Sprintf("ch%d-%s-%.1f.mp3", n, safeVoice, speed) - return filepath.Join(w.AudioDir(slug), filename) -} - -// AudioPartPath returns the path for an individual audio chunk generated during -// chunked TTS. Part files are named ch{n}-{voice}-{speed}.part{p}.mp3 and are -// deleted after they have been merged into the final AudioPath file. -func (w *Writer) AudioPartPath(slug string, n int, voice string, speed float64, part int) string { - safeVoice := sanitiseVoice(voice) - filename := fmt.Sprintf("ch%d-%s-%.1f.part%d.mp3", n, safeVoice, speed, part) - return filepath.Join(w.AudioDir(slug), filename) -} - -// sanitiseVoice converts a voice name into a string that is safe to embed in a -// filename (only a-z, A-Z, 0-9, '_', '-' are kept; everything else becomes '_'). -func sanitiseVoice(voice string) string { - return strings.Map(func(r rune) rune { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { - return r - } - return '_' - }, voice) -} - -// chapterPath computes the full file path for a chapter. -// -// vol-{volume}/{folderRange}/chapter-{number}.md -// -// Example: vol-0/1-50/chapter-1.md, vol-0/51-100/chapter-51.md -func (w *Writer) chapterPath(slug string, ref scraper.ChapterRef) string { - vol := ref.Volume // 0 == no volume grouping - volDir := fmt.Sprintf("vol-%d", vol) - - // Folder group: chapters 1-50 → "1-50", 51-100 → "51-100", … - lo := ((ref.Number-1)/chaptersPerFolder)*chaptersPerFolder + 1 - hi := lo + chaptersPerFolder - 1 - rangeDir := fmt.Sprintf("%d-%d", lo, hi) - - filename := fmt.Sprintf("chapter-%d.md", ref.Number) - - return filepath.Join(w.bookDir(slug), volDir, rangeDir, filename) -} diff --git a/scraper/scraper b/scraper/scraper deleted file mode 100755 index 9e8e1a6..0000000 Binary files a/scraper/scraper and /dev/null differ diff --git a/scraper/tools.go b/scraper/tools.go new file mode 100644 index 0000000..320cbbd --- /dev/null +++ b/scraper/tools.go @@ -0,0 +1,5 @@ +//go:build tools + +package tools + +import _ "honnef.co/go/tools/cmd/staticcheck" diff --git a/scripts/.runner b/scripts/.runner new file mode 100644 index 0000000..54c7fc4 --- /dev/null +++ b/scripts/.runner @@ -0,0 +1,13 @@ +{ + "WARNING": "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.", + "id": 11, + "uuid": "d5d04e0a-572c-46c0-83be-405508948391", + "name": "runner-mac-1", + "token": "ddf214ce148b4673a186f29cb684b407cb8c2ecc", + "address": "https://gitea.kalekber.cc/", + "labels": [ + "macos-latest:host", + "macos-14:host" + ], + "ephemeral": false +} diff --git a/scripts/link-tooltip.user.js b/scripts/link-tooltip.user.js new file mode 100644 index 0000000..245aaa0 --- /dev/null +++ b/scripts/link-tooltip.user.js @@ -0,0 +1,99 @@ +// ==UserScript== +// @name Link URL Tooltip +// @namespace https://github.com/kalekber/libnovel-v2 +// @version 1.0.0 +// @description Show the destination URL near the cursor when hovering over any link +// @author kalekber +// @match *://*/* +// @run-at document-idle +// @grant none +// ==/UserScript== + +(function () { + 'use strict'; + + // --- Inject styles --- + const style = document.createElement('style'); + style.textContent = ` + #lnk-tooltip { + position: fixed; + display: none; + background-color: #333; + color: #fff; + padding: 5px 10px; + border-radius: 4px; + font-size: 12px; + font-family: monospace; + pointer-events: none; + z-index: 2147483647; + white-space: nowrap; + max-width: 600px; + overflow: hidden; + text-overflow: ellipsis; + box-shadow: 0 2px 6px rgba(0,0,0,0.4); + } + `; + document.head.appendChild(style); + + // --- Inject tooltip element --- + const tooltip = document.createElement('div'); + tooltip.id = 'lnk-tooltip'; + document.body.appendChild(tooltip); + + // --- Helpers --- + function getAnchor(target) { + // Walk up the DOM to find the nearest <a href="..."> + // (handles clicks on nested elements like <a><span>text</span></a>) + return target.closest('a[href]'); + } + + function show(anchor, clientX, clientY) { + tooltip.textContent = anchor.href; + tooltip.style.display = 'block'; + position(clientX, clientY); + } + + function hide() { + tooltip.style.display = 'none'; + } + + function position(clientX, clientY) { + const offset = 12; + const tw = tooltip.offsetWidth; + const th = tooltip.offsetHeight; + const vw = window.innerWidth; + const vh = window.innerHeight; + + let x = clientX + offset; + let y = clientY + offset; + + // Flip horizontally if it would overflow the right edge + if (x + tw > vw - 4) { + x = clientX - tw - offset; + } + // Flip vertically if it would overflow the bottom edge + if (y + th > vh - 4) { + y = clientY - th - offset; + } + + tooltip.style.left = Math.max(0, x) + 'px'; + tooltip.style.top = Math.max(0, y) + 'px'; + } + + // --- Event delegation on document --- + document.addEventListener('mouseover', (e) => { + const anchor = getAnchor(e.target); + if (anchor) show(anchor, e.clientX, e.clientY); + }); + + document.addEventListener('mousemove', (e) => { + if (tooltip.style.display === 'block') { + position(e.clientX, e.clientY); + } + }); + + document.addEventListener('mouseout', (e) => { + const anchor = getAnchor(e.target); + if (anchor) hide(); + }); +})(); diff --git a/scripts/pb-init.sh b/scripts/pb-init.sh new file mode 100755 index 0000000..480bbee --- /dev/null +++ b/scripts/pb-init.sh @@ -0,0 +1,232 @@ +#!/bin/sh +# pb-init.sh — idempotent PocketBase collection bootstrap +# +# Creates all collections required by libnovel. Safe to re-run: POST returns +# 400/422 when a collection already exists; both are treated as success. +# +# Required env vars (with defaults): +# POCKETBASE_URL http://pocketbase:8090 +# POCKETBASE_ADMIN_EMAIL admin@libnovel.local +# POCKETBASE_ADMIN_PASSWORD changeme123 + +set -e + +PB_URL="${POCKETBASE_URL:-http://pocketbase:8090}" +PB_EMAIL="${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" +PB_PASSWORD="${POCKETBASE_ADMIN_PASSWORD:-changeme123}" + +log() { echo "[pb-init] $*"; } + +# ─── 1. Wait for PocketBase to be ready ────────────────────────────────────── +log "waiting for PocketBase at $PB_URL ..." +until wget -qO- "$PB_URL/api/health" > /dev/null 2>&1; do + sleep 2 +done +log "PocketBase is up" + +# ─── 2. Authenticate and obtain a superuser token ──────────────────────────── +log "authenticating as $PB_EMAIL ..." +AUTH_RESPONSE=$(wget -qO- \ + --header="Content-Type: application/json" \ + --post-data="{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}" \ + "$PB_URL/api/collections/_superusers/auth-with-password") + +TOKEN=$(echo "$AUTH_RESPONSE" | sed 's/.*"token":"\([^"]*\)".*/\1/') +if [ -z "$TOKEN" ] || [ "$TOKEN" = "$AUTH_RESPONSE" ]; then + log "ERROR: failed to obtain auth token. Response: $AUTH_RESPONSE" + exit 1 +fi +log "auth token obtained" + +# ─── 3. Helpers ─────────────────────────────────────────────────────────────── + +create_collection() { + NAME="$1" + BODY="$2" + STATUS=$(wget -qSO- \ + --header="Content-Type: application/json" \ + --header="Authorization: Bearer $TOKEN" \ + --post-data="$BODY" \ + "$PB_URL/api/collections" 2>&1 | grep "^ HTTP/" | awk '{print $2}') + case "$STATUS" in + 200|201) log "created collection: $NAME" ;; + 400|422) log "collection already exists (skipped): $NAME" ;; + *) log "WARNING: unexpected status $STATUS for collection: $NAME" ;; + esac +} + +# ensure_field COLLECTION FIELD_NAME FIELD_TYPE +# +# Checks whether FIELD_NAME exists in COLLECTION's schema. If it is missing, +# sends a PATCH with the full current fields list plus the new field appended. +# Uses only busybox sh + wget + sed/awk — no python/jq required. +ensure_field() { + COLL="$1" + FIELD_NAME="$2" + FIELD_TYPE="$3" + + SCHEMA=$(wget -qO- \ + --header="Authorization: Bearer $TOKEN" \ + "$PB_URL/api/collections/$COLL" 2>/dev/null) + + # Check if the field already exists (look for "name":"<FIELD_NAME>" in the fields array) + if echo "$SCHEMA" | grep -q "\"name\":\"$FIELD_NAME\""; then + log "field $COLL.$FIELD_NAME already exists — skipping" + return + fi + + COLLECTION_ID=$(echo "$SCHEMA" | sed 's/.*"id":"\([^"]*\)".*/\1/') + if [ -z "$COLLECTION_ID" ] || [ "$COLLECTION_ID" = "$SCHEMA" ]; then + log "WARNING: could not get id for collection $COLL — skipping ensure_field" + return + fi + + # Extract current fields array (everything between the outermost [ ] of "fields":[...]) + # and append the new field object before the closing bracket. + CURRENT_FIELDS=$(echo "$SCHEMA" | sed 's/.*"fields":\(\[.*\]\).*/\1/') + # Strip the trailing ] and append the new field + TRIMMED=$(echo "$CURRENT_FIELDS" | sed 's/]$//') + NEW_FIELDS="${TRIMMED},{\"name\":\"${FIELD_NAME}\",\"type\":\"${FIELD_TYPE}\"}]" + PATCH_BODY="{\"fields\":${NEW_FIELDS}}" + + STATUS=$(wget -qSO- \ + --header="Content-Type: application/json" \ + --header="Authorization: Bearer $TOKEN" \ + --body-data="$PATCH_BODY" \ + --method=PATCH \ + "$PB_URL/api/collections/$COLLECTION_ID" 2>&1 | grep "^ HTTP/" | awk '{print $2}') + case "$STATUS" in + 200|201) log "patched $COLL — added field: $FIELD_NAME ($FIELD_TYPE)" ;; + *) log "WARNING: patch returned $STATUS when adding $FIELD_NAME to $COLL" ;; + esac +} + +# ─── 4. Create collections (idempotent — skips if already exist) ───────────── + +create_collection "books" '{ + "name": "books", + "type": "base", + "fields": [ + {"name": "slug", "type": "text", "required": true}, + {"name": "title", "type": "text", "required": true}, + {"name": "author", "type": "text"}, + {"name": "cover", "type": "text"}, + {"name": "status", "type": "text"}, + {"name": "genres", "type": "json"}, + {"name": "summary", "type": "text"}, + {"name": "total_chapters", "type": "number"}, + {"name": "source_url", "type": "text"}, + {"name": "ranking", "type": "number"}, + {"name": "meta_updated", "type": "date"} + ] +}' + +create_collection "chapters_idx" '{ + "name": "chapters_idx", + "type": "base", + "fields": [ + {"name": "slug", "type": "text", "required": true}, + {"name": "number", "type": "number", "required": true}, + {"name": "title", "type": "text"}, + {"name": "date_label", "type": "text"} + ] +}' + +create_collection "ranking" '{ + "name": "ranking", + "type": "base", + "fields": [ + {"name": "rank", "type": "number", "required": true}, + {"name": "slug", "type": "text", "required": true}, + {"name": "title", "type": "text"}, + {"name": "author", "type": "text"}, + {"name": "cover", "type": "text"}, + {"name": "status", "type": "text"}, + {"name": "genres", "type": "json"}, + {"name": "source_url", "type": "text"}, + {"name": "updated", "type": "date"} + ] +}' + +create_collection "progress" '{ + "name": "progress", + "type": "base", + "fields": [ + {"name": "session_id", "type": "text", "required": true}, + {"name": "user_id", "type": "text"}, + {"name": "slug", "type": "text", "required": true}, + {"name": "chapter", "type": "number"}, + {"name": "updated", "type": "date"} + ] +}' + +create_collection "audio_cache" '{ + "name": "audio_cache", + "type": "base", + "fields": [ + {"name": "cache_key", "type": "text", "required": true}, + {"name": "filename", "type": "text"}, + {"name": "updated", "type": "date"} + ] +}' + +create_collection "app_users" '{ + "name": "app_users", + "type": "base", + "fields": [ + {"name": "username", "type": "text", "required": true}, + {"name": "password_hash", "type": "text", "required": true}, + {"name": "role", "type": "text"}, + {"name": "created", "type": "date"}, + {"name": "avatar_url", "type": "text"} + ] +}' + +create_collection "user_settings" '{ + "name": "user_settings", + "type": "base", + "fields": [ + {"name": "session_id", "type": "text", "required": true}, + {"name": "user_id", "type": "text"}, + {"name": "auto_next", "type": "bool"}, + {"name": "voice", "type": "text"}, + {"name": "speed", "type": "number"}, + {"name": "updated", "type": "date"} + ] +}' + +# ─── 5. Schema migrations (idempotent field additions) ─────────────────────── +# Ensures fields added after initial deploy are present in existing instances. + +ensure_field "progress" "user_id" "text" +ensure_field "progress" "audio_time" "number" +ensure_field "user_settings" "user_id" "text" +ensure_field "app_users" "avatar_url" "text" + +create_collection "book_comments" '{ + "name": "book_comments", + "type": "base", + "fields": [ + {"name": "slug", "type": "text", "required": true}, + {"name": "user_id", "type": "text"}, + {"name": "username", "type": "text"}, + {"name": "body", "type": "text", "required": true}, + {"name": "upvotes", "type": "number"}, + {"name": "downvotes", "type": "number"}, + {"name": "created", "type": "date"}, + {"name": "parent_id", "type": "text"} + ] +}' + +create_collection "comment_votes" '{ + "name": "comment_votes", + "type": "base", + "fields": [ + {"name": "comment_id", "type": "text", "required": true}, + {"name": "user_id", "type": "text"}, + {"name": "session_id", "type": "text", "required": true}, + {"name": "vote", "type": "text", "required": true} + ] +}' + +log "all collections ready" diff --git a/scripts/runner-config-mac.yaml b/scripts/runner-config-mac.yaml new file mode 100644 index 0000000..11cf0aa --- /dev/null +++ b/scripts/runner-config-mac.yaml @@ -0,0 +1,39 @@ +log: + level: info + +runner: + file: .runner + capacity: 1 + envs: {} + env_file: .env + timeout: 3h + shutdown_timeout: 0s + insecure: false + fetch_timeout: 5s + fetch_interval: 2s + github_mirror: '' + labels: + - "macos-latest:host" + - "macos-14:host" + +cache: + enabled: true + dir: "" + host: "__HOST_IP__" + port: 8088 + external_server: "" + +container: + network: "" + privileged: false + options: "" + workdir_parent: "" + valid_volumes: [] + docker_host: "" + force_pull: false + force_rebuild: false + require_docker: false + docker_timeout: 0s + +host: + workdir_parent: "" diff --git a/scripts/runner-config.yaml b/scripts/runner-config.yaml new file mode 100644 index 0000000..1e76ad2 --- /dev/null +++ b/scripts/runner-config.yaml @@ -0,0 +1,109 @@ +# Example configuration file, it's safe to copy this as the default config file without any modification. + +# You don't have to copy this file to your instance, +# just run `./act_runner generate-config > config.yaml` to generate a config file. + +log: + # The level of logging, can be trace, debug, info, warn, error, fatal + level: info + +runner: + # Where to store the registration result. + file: .runner + # Execute how many tasks concurrently at the same time. + capacity: 1 + # Extra environment variables to run jobs. + envs: + # Extra environment variables to run jobs from a file. + # It will be ignored if it's empty or the file doesn't exist. + env_file: .env + # The timeout for a job to be finished. + # Please note that the Gitea instance also has a timeout (3h by default) for the job. + # So the job could be stopped by the Gitea instance if its timeout is shorter than this. + timeout: 3h + # The timeout for the runner to wait for running jobs to finish when shutting down. + # Any running jobs that haven't finished after this timeout will be cancelled. + shutdown_timeout: 0s + # Whether skip verifying the TLS certificate of the Gitea instance. + insecure: false + # The timeout for fetching the job from the Gitea instance. + fetch_timeout: 5s + # The interval for fetching the job from the Gitea instance. + fetch_interval: 2s + # The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository. + # It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github, + # and github_mirror is not empty. In this case, + # it replaces https://github.com with the value here, which is useful for some special network environments. + github_mirror: '' + # The labels of a runner are used to determine which jobs the runner can run, and how to run them. + # Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" + # Find more images provided by Gitea at https://gitea.com/gitea/runner-images . + # If it's empty when registering, it will ask for inputting labels. + # If it's empty when execute `daemon`, will use labels in `.runner` file. + labels: + - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" + - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04" + - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04" + +cache: + # Enable cache server to use actions/cache. + enabled: true + # The directory to store the cache data. + # If it's empty, the cache data will be stored in $HOME/.cache/actcache. + dir: "" + # The host of the cache server. + # It's not for the address to listen, but the address to connect from job containers. + # So 0.0.0.0 is a bad choice, leave it empty to detect automatically. + host: "" + # The port of the cache server. + # 0 means to use a random available port. + port: 8088 + # The external cache server URL. Valid only when enable is true. + # If it's specified, act_runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself. + # The URL should generally end with "/". + external_server: "" + +container: + # Specifies the network to which the container will connect. + # Could be host, bridge or the name of a custom network. + # If it's empty, act_runner will create a network automatically. + network: "" + # Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker). + privileged: false + # Any other options to be used when the container is started (e.g., --add-host=my.gitea.url:host-gateway). + options: + + # The parent directory of a job's working directory. + # NOTE: There is no need to add the first '/' of the path as act_runner will add it automatically. + # If the path starts with '/', the '/' will be trimmed. + # For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir + # If it's empty, /workspace will be used. + workdir_parent: + # Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob + # You can specify multiple volumes. If the sequence is empty, no volumes can be mounted. + # For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, youshould change the config to: + # valid_volumes: + # - data + # - /src/*.json + # If you want to allow any volume, please use the following configuration: + # valid_volumes: + # - '**' + valid_volumes: [] + # Overrides the docker client host with the specified one. + # If it's empty, act_runner will find an available docker host automatically. + # If it's "-", act_runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers. + # If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work. + docker_host: "" + # Pull docker image(s) even if already present + force_pull: false + # Rebuild docker image(s) even if already present + force_rebuild: false + # Always require a reachable docker daemon, even if not required by act_runner + require_docker: false + # Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or act_runner + docker_timeout: 0s + +host: + # The parent directory of a job's working directory. + # If it's empty, $HOME/.cache/act/ will be used. + workdir_parent: diff --git a/scripts/setup_runner.sh b/scripts/setup_runner.sh new file mode 100755 index 0000000..1afbd40 --- /dev/null +++ b/scripts/setup_runner.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── usage ───────────────────────────────────────────────────────────────────── +usage() { + echo "Usage: $0 <runner-name>" + echo " runner-name: runner-node-1 | runner-node-2 | runner-node-3" + exit 1 +} + +[[ $# -ne 1 ]] && usage + +RUNNER_NAME="$1" + +# validate +case "$RUNNER_NAME" in + runner-node-1|runner-node-2|runner-node-3) ;; + *) echo "ERROR: unknown runner name '$RUNNER_NAME'"; usage ;; +esac + +# ── config ──────────────────────────────────────────────────────────────────── +CACHE_PORT=8088 +GITEA_URL="https://gitea.kalekber.cc/" +REGISTRATION_TOKEN="AboxpDKWx7gizwJ9xeheHVqKjj9J9N9BgyX96wvu" +IMAGE="docker.io/gitea/act_runner:latest" +DATA_DIR="$PWD/data/$RUNNER_NAME" +CFG_PATH="$DATA_DIR/config.yaml" + +# ── detect THIS machine's LAN IP ────────────────────────────────────────────── +HOST_IP=$(ip route get 1.1.1.1 | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1); exit}') +if [[ -z "$HOST_IP" ]]; then + echo "ERROR: could not detect host LAN IP" >&2 + exit 1 +fi +echo "Host LAN IP: $HOST_IP" + +# ── generate config.yaml ────────────────────────────────────────────────────── +mkdir -p "$DATA_DIR" + +docker run --rm --entrypoint="" "$IMAGE" \ + act_runner generate-config > "$CFG_PATH" + +awk -v host="$HOST_IP" -v port="$CACHE_PORT" ' + /^cache:/ { in_cache=1 } + in_cache && /enabled:/ { $0 = " enabled: true" } + in_cache && /dir:/ { $0 = " dir: \"/data/cache\"" } + in_cache && /host:/ { $0 = " host: \"" host "\"" } + in_cache && /port:/ { $0 = " port: " port; in_cache=0 } + { print } +' "$CFG_PATH" > "${CFG_PATH}.tmp" && mv "${CFG_PATH}.tmp" "$CFG_PATH" + +echo "Config written to $CFG_PATH (cache $HOST_IP:$CACHE_PORT)" + +# ── stop + remove old container if exists ──────────────────────────────────── +if docker inspect "$RUNNER_NAME" &>/dev/null; then + echo "Removing existing $RUNNER_NAME..." + docker stop "$RUNNER_NAME" || true + docker rm "$RUNNER_NAME" || true +fi + +# ── start runner ────────────────────────────────────────────────────────────── +docker run \ + -v "$DATA_DIR:/data" \ + -v "$CFG_PATH:/config.yaml" \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e CONFIG_FILE=/config.yaml \ + -e GITEA_INSTANCE_URL="$GITEA_URL" \ + -e GITEA_RUNNER_REGISTRATION_TOKEN="$REGISTRATION_TOKEN" \ + -e GITEA_RUNNER_NAME="$RUNNER_NAME" \ + -p "${CACHE_PORT}:${CACHE_PORT}" \ + --restart unless-stopped \ + --name "$RUNNER_NAME" \ + -d "$IMAGE" + +echo "Runner $RUNNER_NAME started" +docker ps --filter "name=$RUNNER_NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" diff --git a/scripts/setup_runner_mac.sh b/scripts/setup_runner_mac.sh new file mode 100755 index 0000000..d73b720 --- /dev/null +++ b/scripts/setup_runner_mac.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── setup_runner_mac.sh ─────────────────────────────────────────────────────── +# Sets up act_runner as a host-mode runner on macOS for iOS CI/CD. +# Installs the binary, generates a config, registers against Gitea, +# and installs a LaunchDaemon so the runner starts at boot. +# +# Usage: sudo ./setup_runner_mac.sh <runner-name> +# Example: sudo ./setup_runner_mac.sh mac-runner-1 +# ───────────────────────────────────────────────────────────────────────────── + +usage() { + echo "Usage: sudo $0 <runner-name>" + exit 1 +} + +[[ $# -ne 1 ]] && usage +[[ "$EUID" -ne 0 ]] && { echo "ERROR: run with sudo"; exit 1; } + +RUNNER_NAME="$1" +GITEA_URL="https://gitea.kalekber.cc/" +REGISTRATION_TOKEN="AboxpDKWx7gizwJ9xeheHVqKjj9J9N9BgyX96wvu" +CACHE_PORT=8088 +INSTALL_DIR="/usr/local/bin" +WORK_DIR="/var/lib/act_runner" +CONFIG_PATH="/etc/act_runner/config.yaml" +LAUNCHDAEMON_PLIST="/Library/LaunchDaemons/com.gitea.act_runner.plist" + +# ── detect Mac LAN IP ───────────────────────────────────────────────────────── +HOST_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "") +if [[ -z "$HOST_IP" ]]; then + echo "ERROR: could not detect LAN IP via en0/en1. Set cache.host manually in $CONFIG_PATH" + HOST_IP="127.0.0.1" +fi +echo "Host LAN IP: $HOST_IP" + +# ── download act_runner binary ──────────────────────────────────────────────── +ARCH=$(uname -m) +if [[ "$ARCH" == "arm64" ]]; then + BINARY_URL="https://gitea.com/gitea/act_runner/releases/download/v0.3.0/act_runner-0.3.0-darwin-arm64" +else + BINARY_URL="https://gitea.com/gitea/act_runner/releases/download/v0.3.0/act_runner-0.3.0-darwin-amd64" +fi + +echo "Downloading act_runner for $ARCH..." +curl -fsSL "$BINARY_URL" -o "$INSTALL_DIR/act_runner" +chmod +x "$INSTALL_DIR/act_runner" +echo "Installed: $("$INSTALL_DIR/act_runner" --version)" + +# ── create working directory ────────────────────────────────────────────────── +mkdir -p "$WORK_DIR" +mkdir -p "$(dirname "$CONFIG_PATH")" + +# ── install config ──────────────────────────────────────────────────────────── +# Use the checked-in static config and substitute the LAN IP placeholder. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +sed "s/__HOST_IP__/$HOST_IP/" "$SCRIPT_DIR/runner-config-mac.yaml" > "$CONFIG_PATH" +echo "Config written: labels=macos-latest:host, cache=$HOST_IP:$CACHE_PORT" + +# ── register runner ─────────────────────────────────────────────────────────── +echo "Registering runner '$RUNNER_NAME'..." +"$INSTALL_DIR/act_runner" register \ + --no-interactive \ + --config "$CONFIG_PATH" \ + --instance "$GITEA_URL" \ + --token "$REGISTRATION_TOKEN" \ + --name "$RUNNER_NAME" \ + --labels "macos-latest:host,macos-14:host" + +# Copy .runner file to work dir if it was created in cwd +[[ -f ".runner" ]] && cp .runner "$WORK_DIR/.runner" + +# ── install LaunchDaemon ────────────────────────────────────────────────────── +# PATH must include Homebrew + Xcode tools so xcodebuild, xcrun, npm, etc. are found. +HOMEBREW_PREFIX=$([ "$ARCH" = "arm64" ] && echo "/opt/homebrew" || echo "/usr/local") + +cat > "$LAUNCHDAEMON_PLIST" <<PLIST +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>Label</key> + <string>com.gitea.act_runner</string> + <key>ProgramArguments</key> + <array> + <string>${INSTALL_DIR}/act_runner</string> + <string>daemon</string> + <string>--config</string> + <string>${CONFIG_PATH}</string> + </array> + <key>RunAtLoad</key> + <true/> + <key>KeepAlive</key> + <true/> + <key>WorkingDirectory</key> + <string>${WORK_DIR}</string> + <key>StandardOutPath</key> + <string>${WORK_DIR}/act_runner.log</string> + <key>StandardErrorPath</key> + <string>${WORK_DIR}/act_runner.err</string> + <key>EnvironmentVariables</key> + <dict> + <key>PATH</key> + <string>${HOMEBREW_PREFIX}/bin:${HOMEBREW_PREFIX}/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Applications/Xcode.app/Contents/Developer/usr/bin</string> + <key>HOME</key> + <string>${WORK_DIR}</string> + </dict> +</dict> +</plist> +PLIST + +echo "LaunchDaemon written to $LAUNCHDAEMON_PLIST" + +# ── load the daemon ─────────────────────────────────────────────────────────── +launchctl unload "$LAUNCHDAEMON_PLIST" 2>/dev/null || true +launchctl load "$LAUNCHDAEMON_PLIST" +echo "Runner '$RUNNER_NAME' started via LaunchDaemon" +echo "" +echo "Useful commands:" +echo " View logs: tail -f $WORK_DIR/act_runner.log" +echo " Stop runner: sudo launchctl unload $LAUNCHDAEMON_PLIST" +echo " Start runner: sudo launchctl load $LAUNCHDAEMON_PLIST" diff --git a/scripts/test-ci-signing.sh b/scripts/test-ci-signing.sh new file mode 100755 index 0000000..8fb32f7 --- /dev/null +++ b/scripts/test-ci-signing.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -euo pipefail + +cd "$(dirname "$0")/../ios/LibNovel" + +echo "=== Testing CI-like signing process ===" + +# 1. Install provisioning profile (simulate CI) +PP_PATH=~/Downloads/LibNovel_Distribution.mobileprovision +UUID=$(security cms -D -i "$PP_PATH" | plutil -extract UUID raw -) +PROFILE_NAME=$(security cms -D -i "$PP_PATH" | plutil -extract Name raw -) +mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles +cp "$PP_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision + +echo "Installed profile: $PROFILE_NAME (UUID: $UUID)" + +# 2. Generate Xcode project +echo "Generating Xcode project..." +xcodegen generate --spec project.yml --project . + +# 3. List available provisioning profiles +echo -e "\n=== Available provisioning profiles ===" +ls -la ~/Library/MobileDevice/Provisioning\ Profiles/ + +# 4. Try building with xcodebuild using manual signing +echo -e "\n=== Attempting archive with manual signing ===" +xcodebuild archive \ + -scheme LibNovel \ + -project LibNovel.xcodeproj \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath /tmp/LibNovel.xcarchive \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="Apple Distribution: Kamil Alekberov (GHZXC6FVMU)" \ + DEVELOPMENT_TEAM=GHZXC6FVMU \ + PROVISIONING_PROFILE_SPECIFIER="$UUID" \ + | xcpretty || true + +echo -e "\n=== Build complete ===" diff --git a/scripts/test-ios-build-simple.sh b/scripts/test-ios-build-simple.sh new file mode 100755 index 0000000..82d5553 --- /dev/null +++ b/scripts/test-ios-build-simple.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Simple iOS build test without fastlane +# Run from project root: ./scripts/test-ios-build-simple.sh /path/to/profile.mobileprovision + +set -e + +PROFILE_PATH="$1" + +if [ -z "$PROFILE_PATH" ]; then + echo "Usage: $0 /path/to/profile.mobileprovision" + exit 1 +fi + +echo "=== Step 1: Extract profile info ===" +UUID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract UUID raw -) +PROFILE_NAME=$(security cms -D -i "$PROFILE_PATH" | plutil -extract Name raw -) +TEAM_ID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract TeamIdentifier.0 raw -) + +echo "Profile Name: $PROFILE_NAME" +echo "UUID: $UUID" +echo "Team ID: $TEAM_ID" + +echo "" +echo "=== Step 2: Install profile ===" +mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles +cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision +echo "✓ Installed" + +echo "" +echo "=== Step 3: Check signing identities ===" +security find-identity -v -p codesigning + +echo "" +echo "=== Step 4: Generate Xcode project ===" +cd ios/LibNovel +export USER=runner +xcodegen generate --spec project.yml --project . +echo "✓ Project generated" + +echo "" +echo "=== Step 5: Try automatic signing build ===" +xcodebuild archive \ + -project LibNovel.xcodeproj \ + -scheme LibNovel \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath ./build/LibNovel.xcarchive \ + -allowProvisioningUpdates \ + CODE_SIGN_STYLE=Automatic \ + DEVELOPMENT_TEAM="$TEAM_ID" + +echo "" +echo "=== ✓ BUILD SUCCEEDED! ===" diff --git a/scripts/test-ios-build.sh b/scripts/test-ios-build.sh new file mode 100755 index 0000000..a255712 --- /dev/null +++ b/scripts/test-ios-build.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Local build test script +# Run from the project root: ./scripts/test-ios-build.sh /path/to/your/profile.mobileprovision + +set -e + +PROFILE_PATH="$1" + +if [ -z "$PROFILE_PATH" ]; then + echo "Usage: $0 /path/to/profile.mobileprovision" + exit 1 +fi + +if [ ! -f "$PROFILE_PATH" ]; then + echo "Error: Profile not found at $PROFILE_PATH" + exit 1 +fi + +echo "=== Extracting profile info ===" +UUID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract UUID raw -) +PROFILE_NAME=$(security cms -D -i "$PROFILE_PATH" | plutil -extract Name raw -) +BUNDLE_ID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract Entitlements.application-identifier raw - 2>/dev/null | sed 's/.*\.//') +TEAM_ID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract TeamIdentifier.0 raw -) + +echo "Profile Name: $PROFILE_NAME" +echo "UUID: $UUID" +echo "Bundle ID: $BUNDLE_ID" +echo "Team ID: $TEAM_ID" + +echo "" +echo "=== Installing provisioning profile ===" +mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles +cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision +echo "Installed to: ~/Library/MobileDevice/Provisioning Profiles/$UUID.mobileprovision" + +echo "" +echo "=== Listing signing identities ===" +security find-identity -v -p codesigning + +echo "" +echo "=== Navigating to iOS project ===" +cd ios/LibNovel + +echo "" +echo "=== Generating Xcode project ===" +xcodegen generate --spec project.yml --project . + +echo "" +echo "=== Testing fastlane build ===" +export USER=runner +export BUILD_NUMBER=999 +export PROVISIONING_PROFILE_NAME="$PROFILE_NAME" + +# Run fastlane beta lane +fastlane beta --verbose + +echo "" +echo "=== Build succeeded! ===" diff --git a/ui/.env.example b/ui/.env.example new file mode 100644 index 0000000..a887a51 --- /dev/null +++ b/ui/.env.example @@ -0,0 +1,20 @@ +# libnovel UI — environment variables +# Copy to .env and adjust; do NOT commit with real secrets. + +# Public URL of the scraper API (used by SvelteKit server-side load functions) +# In docker-compose this is the internal service name +SCRAPER_API_URL=http://localhost:8080 + +# Public URL of PocketBase (used by SvelteKit server-side load functions) +POCKETBASE_URL=http://localhost:8090 + +# PocketBase admin credentials (server-side only, never exposed to browser) +POCKETBASE_ADMIN_EMAIL=admin@libnovel.local +POCKETBASE_ADMIN_PASSWORD=changeme123 + +# Public-facing MinIO URL (used to rewrite presigned URLs for the browser) +# In dev this is localhost; in prod set to your MinIO public domain +PUBLIC_MINIO_PUBLIC_URL=http://localhost:9000 + +# Secret used to sign auth tokens stored in cookies (generate with: openssl rand -hex 32) +AUTH_SECRET=change_this_to_a_long_random_secret diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/ui/.npmrc b/ui/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/ui/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 0000000..c5bfba9 --- /dev/null +++ b/ui/Dockerfile @@ -0,0 +1,32 @@ +FROM node:22-alpine AS builder +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +# Build-time version info — injected by docker-compose or CI via --build-arg. +ARG BUILD_VERSION=dev +ARG BUILD_COMMIT=unknown + +# Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them. +ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION +ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT + +RUN npm run build + +# ── Runtime image ────────────────────────────────────────────────────────────── +# adapter-node bundles all dependencies into build/ — no npm install needed. +FROM node:22-alpine +WORKDIR /app + +COPY --from=builder /app/build ./build + +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOST=0.0.0.0 + +EXPOSE $PORT +CMD ["node", "build"] + diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..7c12da4 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.12.4 create --template minimal --types ts --install npm ui +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..1c8f9ab --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,4160 @@ +{ + "name": "ui", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui", + "version": "0.0.1", + "dependencies": { + "@aws-sdk/client-s3": "^3.1005.0", + "@aws-sdk/s3-request-presigner": "^3.1005.0", + "cropperjs": "^1.6.2", + "marked": "^17.0.3", + "pocketbase": "^0.26.8" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/adapter-node": "^5.5.4", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.1", + "@types/node": "^25.3.3", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1005.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1005.0.tgz", + "integrity": "sha512-EVl5IElgh7l9M242JYZGBt2AtdylpSKEFiEHBfB2OKuh2es19IQkDNfLFGfzThXWbapfBjXuB0zs9nplNviOSQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/credential-provider-node": "^3.972.19", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.7", + "@aws-sdk/middleware-expect-continue": "^3.972.7", + "@aws-sdk/middleware-flexible-checksums": "^3.973.5", + "@aws-sdk/middleware-host-header": "^3.972.7", + "@aws-sdk/middleware-location-constraint": "^3.972.7", + "@aws-sdk/middleware-logger": "^3.972.7", + "@aws-sdk/middleware-recursion-detection": "^3.972.7", + "@aws-sdk/middleware-sdk-s3": "^3.972.19", + "@aws-sdk/middleware-ssec": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.20", + "@aws-sdk/region-config-resolver": "^3.972.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@aws-sdk/util-user-agent-browser": "^3.972.7", + "@aws-sdk/util-user-agent-node": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.9", + "@smithy/eventstream-serde-browser": "^4.2.11", + "@smithy/eventstream-serde-config-resolver": "^4.3.11", + "@smithy/eventstream-serde-node": "^4.2.11", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/hash-blob-browser": "^4.2.12", + "@smithy/hash-node": "^4.2.11", + "@smithy/hash-stream-node": "^4.2.11", + "@smithy/invalid-dependency": "^4.2.11", + "@smithy/md5-js": "^4.2.11", + "@smithy/middleware-content-length": "^4.2.11", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/middleware-retry": "^4.4.40", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.39", + "@smithy/util-defaults-mode-node": "^4.2.42", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.19.tgz", + "integrity": "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/xml-builder": "^3.972.10", + "@smithy/core": "^3.23.9", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/signature-v4": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.4.tgz", + "integrity": "sha512-HKZIZLbRyvzo/bXZU7Zmk6XqU+1C9DjI56xd02vwuDIxedxBEqP17t9ExhbP9QFeNq/a3l9GOcyirFXxmbDhmw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.17.tgz", + "integrity": "sha512-MBAMW6YELzE1SdkOniqr51mrjapQUv8JXSGxtwRjQV0mwVDutVsn22OPAUt4RcLRvdiHQmNBDEFP9iTeSVCOlA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.19.tgz", + "integrity": "sha512-9EJROO8LXll5a7eUFqu48k6BChrtokbmgeMWmsH7lBb6lVbtjslUYz/ShLi+SHkYzTomiGBhmzTW7y+H4BxsnA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/util-stream": "^4.5.17", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.18.tgz", + "integrity": "sha512-vthIAXJISZnj2576HeyLBj4WTeX+I7PwWeRkbOa0mVX39K13SCGxCgOFuKj2ytm9qTlLOmXe4cdEnroteFtJfw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/credential-provider-env": "^3.972.17", + "@aws-sdk/credential-provider-http": "^3.972.19", + "@aws-sdk/credential-provider-login": "^3.972.18", + "@aws-sdk/credential-provider-process": "^3.972.17", + "@aws-sdk/credential-provider-sso": "^3.972.18", + "@aws-sdk/credential-provider-web-identity": "^3.972.18", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.18.tgz", + "integrity": "sha512-kINzc5BBxdYBkPZ0/i1AMPMOk5b5QaFNbYMElVw5QTX13AKj6jcxnv/YNl9oW9mg+Y08ti19hh01HhyEAxsSJQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.19.tgz", + "integrity": "sha512-yDWQ9dFTr+IMxwanFe7+tbN5++q8psZBjlUwOiCXn1EzANoBgtqBwcpYcHaMGtn0Wlfj4NuXdf2JaEx1lz5RaQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.17", + "@aws-sdk/credential-provider-http": "^3.972.19", + "@aws-sdk/credential-provider-ini": "^3.972.18", + "@aws-sdk/credential-provider-process": "^3.972.17", + "@aws-sdk/credential-provider-sso": "^3.972.18", + "@aws-sdk/credential-provider-web-identity": "^3.972.18", + "@aws-sdk/types": "^3.973.5", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.17.tgz", + "integrity": "sha512-c8G8wT1axpJDgaP3xzcy+q8Y1fTi9A2eIQJvyhQ9xuXrUZhlCfXbC0vM9bM1CUXiZppFQ1p7g0tuUMvil/gCPg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.18.tgz", + "integrity": "sha512-YHYEfj5S2aqInRt5ub8nDOX8vAxgMvd84wm2Y3WVNfFa/53vOv9T7WOAqXI25qjj3uEcV46xxfqdDQk04h5XQA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/token-providers": "3.1005.0", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.18.tgz", + "integrity": "sha512-OqlEQpJ+J3T5B96qtC1zLLwkBloechP+fezKbCH0sbd2cCc0Ra55XpxWpk/hRj69xAOYtHvoC4orx6eTa4zU7g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.7.tgz", + "integrity": "sha512-goX+axlJ6PQlRnzE2bQisZ8wVrlm6dXJfBzMJhd8LhAIBan/w1Kl73fJnalM/S+18VnpzIHumyV6DtgmvqG5IA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.7.tgz", + "integrity": "sha512-mvWqvm61bmZUKmmrtl2uWbokqpenY3Mc3Jf4nXB/Hse6gWxLPaCQThmhPBDzsPSV8/Odn8V6ovWt3pZ7vy4BFQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.973.5.tgz", + "integrity": "sha512-Dp3hqE5W6hG8HQ3Uh+AINx9wjjqYmFHbxede54sGj3akx/haIQrkp85lNdTdC+ouNUcSYNiuGkzmyDREfHX1Gg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/crc64-nvme": "^3.972.4", + "@aws-sdk/types": "^3.973.5", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.7.tgz", + "integrity": "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.7.tgz", + "integrity": "sha512-vdK1LJfffBp87Lj0Bw3WdK1rJk9OLDYdQpqoKgmpIZPe+4+HawZ6THTbvjhJt4C4MNnRrHTKHQjkwBiIpDBoig==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.7.tgz", + "integrity": "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.7.tgz", + "integrity": "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.19.tgz", + "integrity": "sha512-/CtOHHVFg4ZuN6CnLnYkrqWgVEnbOBC4kNiKa+4fldJ9cioDt3dD/f5vpq0cWLOXwmGL2zgVrVxNhjxWpxNMkg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.9", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/signature-v4": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.7.tgz", + "integrity": "sha512-G9clGVuAml7d8DYzY6DnRi7TIIDRvZ3YpqJPz/8wnWS5fYx/FNWNmkO6iJVlVkQg9BfeMzd+bVPtPJOvC4B+nQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.20.tgz", + "integrity": "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@smithy/core": "^3.23.9", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-retry": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.8.tgz", + "integrity": "sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/middleware-host-header": "^3.972.7", + "@aws-sdk/middleware-logger": "^3.972.7", + "@aws-sdk/middleware-recursion-detection": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.20", + "@aws-sdk/region-config-resolver": "^3.972.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@aws-sdk/util-user-agent-browser": "^3.972.7", + "@aws-sdk/util-user-agent-node": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.9", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/hash-node": "^4.2.11", + "@smithy/invalid-dependency": "^4.2.11", + "@smithy/middleware-content-length": "^4.2.11", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/middleware-retry": "^4.4.40", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.39", + "@smithy/util-defaults-mode-node": "^4.2.42", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.7.tgz", + "integrity": "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1005.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1005.0.tgz", + "integrity": "sha512-5lM7MxgIyF64RFNc70dqcjA1WlPXP/vwLOsJ/7+zFjiubu3q5xaTAw7kdAUXk+BRkYiweO4OVz2v4MHNugY7OA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-format-url": "^3.972.7", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.7.tgz", + "integrity": "sha512-mYhh7FY+7OOqjkYkd6+6GgJOsXK1xBWmuR+c5mxJPj2kr5TBNeZq+nUvE9kANWAux5UxDVrNOSiEM/wlHzC3Lg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/protocol-http": "^5.3.11", + "@smithy/signature-v4": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1005.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1005.0.tgz", + "integrity": "sha512-vMxd+ivKqSxU9bHx5vmAlFKDAkjGotFU56IOkDa5DaTu1WWwbcse0yFHEm9I537oVvodaiwMl3VBwgHfzQ2rvw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.5.tgz", + "integrity": "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", + "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-endpoints": "^3.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.7.tgz", + "integrity": "sha512-V+PbnWfUl93GuFwsOHsAq7hY/fnm9kElRqR8IexIJr5Rvif9e614X5sGSyz3mVSf1YAZ+VTy63W1/pGdA55zyA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/querystring-builder": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.7.tgz", + "integrity": "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.5.tgz", + "integrity": "sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.20", + "@aws-sdk/types": "^3.973.5", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz", + "integrity": "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "fast-xml-parser": "5.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.0.tgz", + "integrity": "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz", + "integrity": "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.10.tgz", + "integrity": "sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.9", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.9.tgz", + "integrity": "sha512-1Vcut4LEL9HZsdpI0vFiRYIsaoPwZLjAxnVQDUMQK8beMS+EYPLDQCXtbzfxmM5GzSgjfe2Q9M7WaXwIMQllyQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.12", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.11.tgz", + "integrity": "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.11.tgz", + "integrity": "sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.13.0", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.11.tgz", + "integrity": "sha512-3rEpo3G6f/nRS7fQDsZmxw/ius6rnlIpz4UX6FlALEzz8JoSxFmdBt0SZnthis+km7sQo6q5/3e+UJcuQivoXA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.11.tgz", + "integrity": "sha512-XeNIA8tcP/GDWnnKkO7qEm/bg0B/bP9lvIXZBXcGZwZ+VYM8h8k9wuDvUODtdQ2Wcp2RcBkPTCSMmaniVHrMlA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.11.tgz", + "integrity": "sha512-fzbCh18rscBDTQSCrsp1fGcclLNF//nJyhjldsEl/5wCYmgpHblv5JSppQAyQI24lClsFT0wV06N1Porn0IsEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.11.tgz", + "integrity": "sha512-MJ7HcI+jEkqoWT5vp+uoVaAjBrmxBtKhZTeynDRG/seEjJfqyg3SiqMMqyPnAMzmIfLaeJ/uiuSDP/l9AnMy/Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.13.tgz", + "integrity": "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.11", + "@smithy/querystring-builder": "^4.2.11", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.12.tgz", + "integrity": "sha512-1wQE33DsxkM/waftAhCH9VtJbUGyt1PJ9YRDpOu+q9FUi73LLFUZ2fD8A61g2mT1UY9k7b99+V1xZ41Rz4SHRQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.11.tgz", + "integrity": "sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.11.tgz", + "integrity": "sha512-hQsTjwPCRY8w9GK07w1RqJi3e+myh0UaOWBBhZ1UMSDgofH/Q1fEYzU1teaX6HkpX/eWDdm7tAGR0jBPlz9QEQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.11.tgz", + "integrity": "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.11.tgz", + "integrity": "sha512-350X4kGIrty0Snx2OWv7rPM6p6vM7RzryvFs6B/56Cux3w3sChOb3bymo5oidXJlPcP9fIRxGUCk7GqpiSOtng==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.11.tgz", + "integrity": "sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.23", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.23.tgz", + "integrity": "sha512-UEFIejZy54T1EJn2aWJ45voB7RP2T+IRzUqocIdM6GFFa5ClZncakYJfcYnoXt3UsQrZZ9ZRauGm77l9UCbBLw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.9", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-middleware": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.40", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.40.tgz", + "integrity": "sha512-YhEMakG1Ae57FajERdHNZ4ShOPIY7DsgV+ZoAxo/5BT0KIe+f6DDU2rtIymNNFIj22NJfeeI6LWIifrwM0f+rA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/service-error-classification": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.12.tgz", + "integrity": "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.11.tgz", + "integrity": "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.11.tgz", + "integrity": "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.14.tgz", + "integrity": "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/querystring-builder": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.11.tgz", + "integrity": "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.11.tgz", + "integrity": "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.11.tgz", + "integrity": "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "@smithy/util-uri-escape": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.11.tgz", + "integrity": "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.11.tgz", + "integrity": "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.6.tgz", + "integrity": "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.11.tgz", + "integrity": "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.3.tgz", + "integrity": "sha512-7k4UxjSpHmPN2AxVhvIazRSzFQjWnud3sOsXcFStzagww17j1cFQYqTSiQ8xuYK3vKLR1Ni8FzuT3VlKr3xCNw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.9", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-stream": "^4.5.17", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", + "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.11.tgz", + "integrity": "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.39", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.39.tgz", + "integrity": "sha512-ui7/Ho/+VHqS7Km2wBw4/Ab4RktoiSshgcgpJzC4keFPs6tLJS4IQwbeahxQS3E/w98uq6E1mirCH/id9xIXeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.42", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.42.tgz", + "integrity": "sha512-QDA84CWNe8Akpj15ofLO+1N3Rfg8qa2K5uX0y6HnOp4AnRYRgWrKx/xzbYNbVF9ZsyJUYOfcoaN3y93wA/QJ2A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.10", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.2.tgz", + "integrity": "sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.11.tgz", + "integrity": "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.11.tgz", + "integrity": "sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.17", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.17.tgz", + "integrity": "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.11.tgz", + "integrity": "sha512-x7Rh2azQPs3XxbvCzcttRErKKvLnbZfqRf/gOjw2pb+ZscX88e5UkRPCB67bVnsFHxayvMvmePfKTqsRb+is1A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.4.tgz", + "integrity": "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.53.4", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.4.tgz", + "integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.3", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cropperjs": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz", + "integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", + "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", + "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.0.0", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz", + "integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pocketbase": { + "version": "0.26.8", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.8.tgz", + "integrity": "sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", + "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strnum": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.53.6", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.6.tgz", + "integrity": "sha512-lP5DGF3oDDI9fhHcSpaBiJEkFLuS16h92DhM1L5K1lFm0WjOmUh1i2sNkBBk8rkxJRpob0dBE75jRfUzGZUOGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.3", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.4.tgz", + "integrity": "sha512-F1pGqXc710Oi/wTI4d/x7d6lgPwwfx1U6w3Q35n4xsC2e8C/yN2sM1+mWxjlMcpAfWucjlq4vPi+P4FZ8a14sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..bd67878 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,34 @@ +{ + "name": "ui", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/adapter-node": "^5.5.4", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.1", + "@types/node": "^25.3.3", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3", + "vite": "^7.3.1" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.1005.0", + "@aws-sdk/s3-request-presigner": "^3.1005.0", + "cropperjs": "^1.6.2", + "marked": "^17.0.3", + "pocketbase": "^0.26.8" + } +} diff --git a/ui/src/app.css b/ui/src/app.css new file mode 100644 index 0000000..c106a57 --- /dev/null +++ b/ui/src/app.css @@ -0,0 +1,65 @@ +@import "tailwindcss"; + +@theme { + --color-brand: #f59e0b; /* amber-400 */ + --color-brand-dim: #d97706; /* amber-600 */ + --color-surface: #18181b; /* zinc-900 */ + --color-surface-2: #27272a; /* zinc-800 */ + --color-surface-3: #3f3f46; /* zinc-700 */ + --color-muted: #a1a1aa; /* zinc-400 */ + --color-text: #f4f4f5; /* zinc-100 */ +} + +html { + background-color: var(--color-surface); + color: var(--color-text); +} + +/* ── Chapter prose ─────────────────────────────────────────────────── */ +.prose-chapter { + max-width: 72ch; + line-height: 1.85; + font-size: 1.05rem; + color: #d4d4d8; /* zinc-300 */ +} + +.prose-chapter h1, +.prose-chapter h2, +.prose-chapter h3 { + color: #f4f4f5; + font-weight: 700; + margin-top: 1.5em; + margin-bottom: 0.5em; +} + +.prose-chapter h1 { font-size: 1.4rem; } +.prose-chapter h2 { font-size: 1.2rem; } +.prose-chapter h3 { font-size: 1.05rem; } + +.prose-chapter p { + margin-bottom: 1.2em; +} + +.prose-chapter em { + color: #a1a1aa; +} + +.prose-chapter strong { + color: #f4f4f5; +} + +.prose-chapter hr { + border-color: #3f3f46; + margin: 2em 0; +} + +/* ── Navigation progress bar ───────────────────────────────────────── */ +@keyframes progress-bar { + 0% { width: 0%; opacity: 1; } + 80% { width: 90%; opacity: 1; } + 100% { width: 100%; opacity: 0; } +} +.animate-progress-bar { + animation: progress-bar 8s cubic-bezier(0.1, 0.05, 0.1, 1) forwards; +} + diff --git a/ui/src/app.d.ts b/ui/src/app.d.ts new file mode 100644 index 0000000..75eecc0 --- /dev/null +++ b/ui/src/app.d.ts @@ -0,0 +1,18 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + interface Locals { + sessionId: string; + user: { id: string; username: string; role: string; authSessionId: string } | null; + } + interface PageData { + user?: { id: string; username: string; role: string; authSessionId: string } | null; + } + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/ui/src/app.html b/ui/src/app.html new file mode 100644 index 0000000..c1b5e52 --- /dev/null +++ b/ui/src/app.html @@ -0,0 +1,17 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="icon" href="/favicon.ico" sizes="16x16 32x32" /> + <link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" /> + <link rel="icon" type="image/png" href="/favicon-16.png" sizes="16x16" /> + <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> + <link rel="icon" type="image/png" href="/icon-192.png" sizes="192x192" /> + <link rel="icon" type="image/png" href="/icon-512.png" sizes="512x512" /> + %sveltekit.head% + </head> + <body data-sveltekit-preload-data="hover"> + <div style="display: contents">%sveltekit.body%</div> + </body> +</html> diff --git a/ui/src/hooks.server.ts b/ui/src/hooks.server.ts new file mode 100644 index 0000000..15a41ca --- /dev/null +++ b/ui/src/hooks.server.ts @@ -0,0 +1,124 @@ +import type { Handle } from '@sveltejs/kit'; +import { randomBytes, createHmac } from 'node:crypto'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; +import { createUserSession, touchUserSession, isSessionRevoked } from '$lib/server/pocketbase'; + +const SESSION_COOKIE = 'libnovel_session'; +const AUTH_COOKIE = 'libnovel_auth'; +const ONE_YEAR = 60 * 60 * 24 * 365; + +const AUTH_SECRET = env.AUTH_SECRET ?? 'dev_secret_change_in_production'; + +// ─── Token helpers ──────────────────────────────────────────────────────────── + +/** + * Sign a payload string with HMAC-SHA256 using AUTH_SECRET. + * Returns "<payload>.<signature>". + */ +export function signToken(payload: string): string { + const sig = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex'); + return `${payload}.${sig}`; +} + +/** + * Verify a signed token. Returns the payload string on success, null on failure. + */ +export function verifyToken(token: string): string | null { + const lastDot = token.lastIndexOf('.'); + if (lastDot < 0) return null; + const payload = token.slice(0, lastDot); + const expected = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex'); + const actual = token.slice(lastDot + 1); + // constant-time comparison + if (expected.length !== actual.length) return null; + let diff = 0; + for (let i = 0; i < expected.length; i++) { + diff |= expected.charCodeAt(i) ^ actual.charCodeAt(i); + } + return diff === 0 ? payload : null; +} + +/** + * Create a signed auth token for a user. + * Payload format: "<userId>:<username>:<role>:<authSessionId>" + * authSessionId uniquely identifies this login session (for revocation). + */ +export function createAuthToken(userId: string, username: string, role: string, authSessionId: string): string { + return signToken(`${userId}:${username}:${role}:${authSessionId}`); +} + +/** + * Parse a verified auth token into user data. Returns null if invalid. + * Supports both old format (3 segments) and new format (4 segments). + */ +export function parseAuthToken(token: string): { id: string; username: string; role: string; authSessionId: string } | null { + const payload = verifyToken(token); + if (!payload) return null; + const parts = payload.split(':'); + // New format: userId:username:role:authSessionId (4 parts) + // Old format: userId:username:role (3 parts — legacy tokens before session tracking) + if (parts.length < 3) return null; + const id = parts[0]; + const username = parts[1]; + const role = parts[2]; + const authSessionId = parts[3] ?? ''; // empty string for legacy tokens + if (!id || !username) return null; + return { id, username, role, authSessionId }; +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +export const handle: Handle = async ({ event, resolve }) => { + // Anonymous session cookie (for reading progress) + let sessionId = event.cookies.get(SESSION_COOKIE) ?? ''; + if (!sessionId) { + sessionId = randomBytes(16).toString('hex'); + event.cookies.set(SESSION_COOKIE, sessionId, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: ONE_YEAR + }); + } + event.locals.sessionId = sessionId; + + // Auth cookie → resolve logged-in user + const authToken = event.cookies.get(AUTH_COOKIE); + if (authToken) { + const user = parseAuthToken(authToken); + if (!user) { + log.warn('auth', 'auth cookie present but failed to parse (malformed or tampered)'); + event.locals.user = null; + } else { + // Validate session against DB (only for new-format tokens with authSessionId) + let sessionValid = true; + if (user.authSessionId) { + try { + const revoked = await isSessionRevoked(user.authSessionId); + if (revoked) { + log.info('auth', 'auth cookie references revoked session', { + userId: user.id, + authSessionId: user.authSessionId + }); + sessionValid = false; + // Clear the invalid cookie + event.cookies.delete(AUTH_COOKIE, { path: '/' }); + } else { + // Best-effort: update last_seen in the background + touchUserSession(user.authSessionId).catch(() => {}); + } + } catch (err) { + // DB error — fail open to avoid locking everyone out + log.warn('auth', 'session check failed (fail open)', { err: String(err) }); + } + } + event.locals.user = sessionValid ? user : null; + } + } else { + event.locals.user = null; + } + + return resolve(event); +}; + diff --git a/ui/src/lib/assets/favicon.svg b/ui/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/ui/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo \ No newline at end of file diff --git a/ui/src/lib/audio.svelte.ts b/ui/src/lib/audio.svelte.ts new file mode 100644 index 0000000..de4f9a0 --- /dev/null +++ b/ui/src/lib/audio.svelte.ts @@ -0,0 +1,146 @@ +/** + * Global audio player state for libnovel. + * + * A single shared instance (module singleton) keeps audio playing across + * SvelteKit navigations. The layout mounts the