Compare commits

..

1 Commits

Author SHA1 Message Date
Admin
3d9d8ab365 Align iOS player UI with new glass design
Some checks failed
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 11s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Test (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 2s
2026-03-07 23:45:40 +05:00
35 changed files with 571 additions and 1463 deletions

View File

@@ -2,20 +2,18 @@ name: CI / Scraper
on:
push:
branches: ["main", "master", "v2"]
branches: ["main", "master"]
paths:
- "scraper/**"
- "justfile"
- ".gitea/workflows/ci-scraper.yaml"
pull_request:
branches: ["main", "master", "v2"]
branches: ["main", "master"]
paths:
- "scraper/**"
- "justfile"
- ".gitea/workflows/ci-scraper.yaml"
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
# ── lint & vet ───────────────────────────────────────────────────────────────
lint:
@@ -29,11 +27,11 @@ jobs:
go-version-file: scraper/go.mod
cache-dependency-path: scraper/go.sum
- name: Install just
uses: extractions/setup-just@v2
- name: go vet
working-directory: scraper
run: |
go vet ./...
go vet -tags integration ./...
run: just lint
# ── tests ────────────────────────────────────────────────────────────────────
test:
@@ -47,30 +45,36 @@ jobs:
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 ./...
- name: Install just
uses: extractions/setup-just@v2
# ── push to Docker Hub ───────────────────────────────────────────────────────
docker:
name: Docker Push
- name: Run tests
run: just test
# ── build binary ─────────────────────────────────────────────────────────────
build:
name: Build
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
- uses: actions/setup-go@v5
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
go-version-file: scraper/go.mod
cache-dependency-path: scraper/go.sum
- name: Build and push
uses: docker/build-push-action@v6
- name: Install just
uses: extractions/setup-just@v2
- name: Build binary
run: |
cd scraper && 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:
context: scraper
push: true
tags: |
${{ secrets.DOCKER_USER }}/libnovel-scraper:latest
${{ secrets.DOCKER_USER }}/libnovel-scraper:${{ gitea.sha }}
name: scraper-linux-amd64
path: scraper/bin/scraper
retention-days: 7

View File

@@ -2,28 +2,23 @@ name: CI / UI
on:
push:
branches: ["main", "master", "v2"]
branches: ["main", "master"]
paths:
- "ui/**"
- "justfile"
- ".gitea/workflows/ci-ui.yaml"
pull_request:
branches: ["main", "master", "v2"]
branches: ["main", "master"]
paths:
- "ui/**"
- "justfile"
- ".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
@@ -33,35 +28,14 @@ jobs:
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Install just
uses: extractions/setup-just@v2
- name: Install dependencies
run: npm ci
run: just ui-install
- name: Type check
run: npm run check
run: just ui-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 }}
run: just ui-build

View File

@@ -19,18 +19,14 @@ on:
# Header: x-api-key: <token>
# Response on success: HTTP 200, body: [{"result":{"data":{"json":{...}}}}]
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
# ── production deploy (main/master only) ─────────────────────────────────────
deploy-production:
name: Deploy Production
runs-on: ubuntu-latest
if: >
gitea.event_name == 'push' &&
(gitea.ref == 'refs/heads/main' || gitea.ref == 'refs/heads/master')
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
steps:
- name: Redeploy production stack
run: |
@@ -50,15 +46,15 @@ jobs:
name: Deploy Preview
runs-on: ubuntu-latest
if: >
gitea.event_name == 'push' &&
gitea.ref != 'refs/heads/main' &&
gitea.ref != 'refs/heads/master'
github.event_name == 'push' &&
github.ref != 'refs/heads/main' &&
github.ref != 'refs/heads/master'
steps:
- name: Sanitize branch name
id: branch
run: |
# Lowercase, replace non-alphanumeric with dashes, strip trailing dashes, max 20 chars
SUFFIX=$(echo "${{ gitea.ref_name }}" \
SUFFIX=$(echo "${{ github.ref_name }}" \
| tr '[:upper:]' '[:lower:]' \
| sed 's/[^a-z0-9]/-/g' \
| cut -c1-20 \
@@ -85,12 +81,12 @@ jobs:
cleanup-preview:
name: Cleanup Preview
runs-on: ubuntu-latest
if: gitea.event_name == 'pull_request' && gitea.event.action == 'closed'
if: github.event_name == 'pull_request' && github.event.action == 'closed'
steps:
- name: Sanitize branch name
id: branch
run: |
SUFFIX=$(echo "${{ gitea.head_ref }}" \
SUFFIX=$(echo "${{ github.head_ref }}" \
| tr '[:upper:]' '[:lower:]' \
| sed 's/[^a-z0-9]/-/g' \
| cut -c1-20 \

View File

@@ -1,118 +0,0 @@
name: iOS Release
on:
push:
tags:
- "ios-v*"
concurrency:
group: ios-macos-runner
cancel-in-progress: false
jobs:
# ── archive & release to TestFlight ──────────────────────────────────────
# Triggered only on ios-v* tags (e.g. ios-v1.0.0).
# Required secrets:
# APPLE_CERTIFICATE_BASE64 - Distribution certificate (.p12) base64-encoded
# APPLE_CERTIFICATE_PASSWORD - Password for the .p12 file
# APPLE_PROVISIONING_PROFILE_BASE64 - App Store distribution profile base64-encoded
# KEYCHAIN_PASSWORD - Temporary keychain password (any random string)
# ASC_KEY_ID - App Store Connect API key ID
# ASC_ISSUER_ID - App Store Connect issuer ID
# ASC_PRIVATE_KEY - Contents of the .p8 private key file
# APPLE_TEAM_ID - 10-character Apple Developer team ID (GHZXC6FVMU)
release:
name: Release to TestFlight
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Install just
run: command -v just || brew install just
- name: Set build number from run number
run: just ios-set-build-number ${{ gitea.run_number }}
- name: Import signing certificate
env:
CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
echo "$CERTIFICATE_BASE64" | base64 --decode > $RUNNER_TEMP/cert.p12
security import $RUNNER_TEMP/cert.p12 \
-P "$CERTIFICATE_PASSWORD" \
-A -t cert -f pkcs12 \
-k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
- name: Import provisioning profile
env:
PROFILE_BASE64: ${{ secrets.APPLE_PROVISIONING_PROFILE_BASE64 }}
run: |
PP_PATH=$RUNNER_TEMP/profile.mobileprovision
echo "$PROFILE_BASE64" | base64 --decode > $PP_PATH
UUID=$(security cms -D -i "$PP_PATH" | plutil -extract UUID raw -)
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
- name: Write App Store Connect API key
env:
ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
run: |
mkdir -p ~/private_keys
echo "$ASC_PRIVATE_KEY" > ~/private_keys/AuthKey_$ASC_KEY_ID.p8
- name: Inject team ID into ExportOptions.plist
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
/usr/libexec/PlistBuddy -c \
"Set :teamID $APPLE_TEAM_ID" \
ios/LibNovel/ExportOptions.plist
- name: Archive
env:
USER: runner
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
PROFILE_UUID=$(security cms -D -i $RUNNER_TEMP/profile.mobileprovision | plutil -extract UUID raw -)
PROFILE_NAME=$(security cms -D -i $RUNNER_TEMP/profile.mobileprovision | plutil -extract Name raw -)
PROFILE_BUNDLE=$(security cms -D -i $RUNNER_TEMP/profile.mobileprovision | plutil -extract Entitlements.application-identifier raw - 2>/dev/null || echo "n/a")
PROFILE_TEAM=$(security cms -D -i $RUNNER_TEMP/profile.mobileprovision | plutil -extract TeamIdentifier.0 raw -)
PROFILE_EXPIRY=$(security cms -D -i $RUNNER_TEMP/profile.mobileprovision | plutil -extract ExpirationDate raw -)
echo "DEBUG: PROFILE_UUID=$PROFILE_UUID"
echo "DEBUG: PROFILE_NAME=$PROFILE_NAME"
echo "DEBUG: PROFILE_BUNDLE=$PROFILE_BUNDLE"
echo "DEBUG: PROFILE_TEAM=$PROFILE_TEAM"
echo "DEBUG: PROFILE_EXPIRY=$PROFILE_EXPIRY"
echo "DEBUG: APPLE_TEAM_ID=$APPLE_TEAM_ID"
echo "DEBUG: profiles dir listing:"
ls ~/Library/MobileDevice/Provisioning\ Profiles/
just ios-archive "$APPLE_TEAM_ID" "$PROFILE_UUID"
- name: Export IPA
run: just ios-export
- name: Upload to TestFlight
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
run: just ios-upload
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
with:
name: LibNovel-${{ gitea.ref_name }}.ipa
path: ${{ env.RUNNER_TEMP }}/ipa/LibNovel.ipa
retention-days: 30
- name: Cleanup keychain
if: always()
run: security delete-keychain $RUNNER_TEMP/app-signing.keychain-db

View File

@@ -7,21 +7,15 @@ on:
- "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 ─────────────────────────────────────────────────────────────────
build:
name: Build
runs-on: macos-latest
@@ -30,14 +24,15 @@ jobs:
- uses: actions/checkout@v4
- name: Install just
run: command -v just || brew install just
run: brew install just
- name: Install xcodegen
run: brew install xcodegen
- name: Build (simulator)
env:
USER: runner
run: just ios-build
# ── unit tests ────────────────────────────────────────────────────────────
# ── test ──────────────────────────────────────────────────────────────────
test:
name: Test
runs-on: macos-latest
@@ -47,11 +42,12 @@ jobs:
- uses: actions/checkout@v4
- name: Install just
run: command -v just || brew install just
run: brew install just
- name: Install xcodegen
run: brew install xcodegen
- name: Run unit tests
env:
USER: runner
run: just ios-test
- name: Upload test results
@@ -61,3 +57,53 @@ jobs:
name: test-results
path: ios/LibNovel/test-results.xml
retention-days: 7
# ── archive (release IPA) ─────────────────────────────────────────────────
# Runs only on pushes to v2/main (not PRs).
# Requires secrets: APPLE_CERTIFICATE_BASE64, APPLE_CERTIFICATE_PASSWORD,
# APPLE_PROVISIONING_PROFILE_BASE64, KEYCHAIN_PASSWORD
# Also requires ios/LibNovel/ExportOptions.plist to exist.
#
# archive:
# name: Archive
# runs-on: macos-latest
# needs: [build, test]
# if: gitea.event_name == 'push'
#
# steps:
# - uses: actions/checkout@v4
#
# - name: Install just
# run: brew install just
#
# - name: Install xcodegen
# run: brew install xcodegen
#
# - name: Import signing certificate
# env:
# CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
# CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
# KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
# run: |
# KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
# security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# echo "$CERTIFICATE_BASE64" | base64 --decode > $RUNNER_TEMP/cert.p12
# security import $RUNNER_TEMP/cert.p12 \
# -P "$CERTIFICATE_PASSWORD" \
# -A -t cert -f pkcs12 \
# -k $KEYCHAIN_PATH
# security list-keychain -d user -s $KEYCHAIN_PATH
#
# - name: Import provisioning profile
# env:
# PROFILE_BASE64: ${{ secrets.APPLE_PROVISIONING_PROFILE_BASE64 }}
# run: |
# PP_PATH=$RUNNER_TEMP/profile.mobileprovision
# echo "$PROFILE_BASE64" | base64 --decode > $PP_PATH
# mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
# cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/
#
# - name: Archive & export IPA
# run: just ios-archive && just ios-export

View File

@@ -5,18 +5,15 @@ on:
tags:
- "v*"
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
defaults:
run:
working-directory: ui
jobs:
# ── type-check & build ───────────────────────────────────────────────────────
build:
name: Build
runs-on: ubuntu-latest
defaults:
run:
working-directory: ui
steps:
- uses: actions/checkout@v4
@@ -40,29 +37,33 @@ jobs:
name: Docker
runs-on: ubuntu-latest
needs: [build]
defaults:
run:
working-directory: .
steps:
- uses: actions/checkout@v4
- name: Log in to Docker Hub
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
registry: gitea.kalekber.cc
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-ui
images: gitea.kalekber.cc/kamil/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
uses: docker/build-push-action@v5
with:
context: ui
context: ./ui
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -1,21 +0,0 @@
<?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>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>$(DEVELOPMENT_TEAM)</string>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
<true/>
<key>signingStyle</key>
<string>manual</string>
<key>provisioningProfiles</key>
<dict>
<key>com.kalekber.LibNovel</key>
<string>LibNovel Distribution</string>
</dict>
</dict>
</plist>

View File

@@ -24,6 +24,7 @@
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; };
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; };
BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 6313AB4B3A5464F647791174 /* MarkdownUI */; };
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 */; };
@@ -85,6 +86,7 @@
buildActionMask = 2147483647;
files = (
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */,
BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -325,6 +327,7 @@
name = LibNovel;
packageProductDependencies = (
09584EAB68A07B47F876A062 /* Kingfisher */,
6313AB4B3A5464F647791174 /* MarkdownUI */,
);
productName = LibNovel;
productReference = 1B8BF3DB582A658386E402C7 /* LibNovel.app */;
@@ -350,6 +353,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */,
C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 6318D3C6F0DC6C8E2C377103 /* Products */;
@@ -666,6 +670,14 @@
minimumVersion = 8.0.0;
};
};
C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.4.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -674,6 +686,11 @@
package = AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
6313AB4B3A5464F647791174 /* MarkdownUI */ = {
isa = XCSwiftPackageProductDependency;
package = C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
productName = MarkdownUI;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = A10A669C0C8B43078C0FEE9F /* Project object */;

View File

@@ -9,6 +9,33 @@
"revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008",
"version" : "8.8.0"
}
},
{
"identity" : "networkimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/NetworkImage",
"state" : {
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
"version" : "6.0.1"
}
},
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark",
"state" : {
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
"version" : "0.7.1"
}
},
{
"identity" : "swift-markdown-ui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
"state" : {
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
"version" : "2.4.1"
}
}
],
"version" : 3

View File

@@ -23,11 +23,11 @@ struct RootTabView: View {
ZStack(alignment: .bottom) {
TabView(selection: $selectedTab) {
HomeView()
.tabItem { Label("Home", systemImage: "house.fill") }
.tabItem { Label("Home", systemImage: "house") }
.tag(Tab.home)
LibraryView()
.tabItem { Label("Library", systemImage: "book.pages.fill") }
.tabItem { Label("Library", systemImage: "books.vertical") }
.tag(Tab.library)
BrowseView()
@@ -35,7 +35,7 @@ struct RootTabView: View {
.tag(Tab.browse)
ProfileView()
.tabItem { Label("Profile", systemImage: "gear") }
.tabItem { Label("Profile", systemImage: "person.crop.circle") }
.tag(Tab.profile)
}
// Reserve space for the mini-player above the tab bar so scroll content
@@ -49,7 +49,7 @@ struct RootTabView: View {
// Mini-player pinned above the tab bar (hidden while full player is open)
if audioPlayer.isActive && !showFullPlayer {
MiniPlayerView(showFullPlayer: $showFullPlayer)
.padding(.bottom, tabBarHeight)
.padding(.bottom, max(tabBarHeight - 6, 0))
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
}

View File

@@ -3,6 +3,7 @@ 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)
static let amberLight = Color(red: 1.0, green: 0.84, blue: 0.40)
}
extension ShapeStyle where Self == Color {

View File

@@ -6,31 +6,3 @@ enum NavDestination: Hashable {
case book(String) // slug
case chapter(String, Int) // slug + chapter number
}
// 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 {
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)
}
}
}
/// Presents a standard "Error" alert driven by an optional String binding.
/// Dismissing the alert sets the binding back to nil.
func errorAlert(_ error: Binding<String?>) -> some View {
alert("Error", isPresented: Binding(
get: { error.wrappedValue != nil },
set: { if !$0 { error.wrappedValue = nil } }
)) {
Button("OK") { error.wrappedValue = nil }
} message: {
Text(error.wrappedValue ?? "")
}
}
}

View File

@@ -3,28 +3,18 @@ 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).
/// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago")
/// that may be embedded in chapter titles coming from older scraped records.
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
// Walk backwards past whitespace to find the numeric token
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
// Find start of last word (should be a number)
if let spaceIdx = trimmed.lastIndex(of: " ") {
let potentialNum = String(trimmed[trimmed.index(after: spaceIdx)...])
if Int(potentialNum) != nil {
@@ -32,6 +22,7 @@ extension String {
.trimmingCharacters(in: .whitespaces)
}
} else if Int(trimmed) != nil {
// The entire string is just a number + "ago"
return ""
}
}
@@ -44,6 +35,6 @@ extension String {
enum AppLayout {
/// Height of the persistent mini-player bar:
/// 12pt vertical padding (top) + 56pt cover height + 12pt vertical padding (bottom) + 12pt horizontal margin.
static let miniPlayerBarHeight: CGFloat = 92
/// 2pt progress line + 20pt vertical padding + ~44pt content row.
static let miniPlayerBarHeight: CGFloat = 66
}

View File

@@ -126,6 +126,22 @@ struct AppUser: Codable, Identifiable {
}
}
// MARK: - Browse / Novel Listing
struct NovelListing: 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]
}
// MARK: - Ranking
struct RankingItem: Codable, Identifiable {
@@ -185,6 +201,17 @@ struct UserSession: Codable, Identifiable {
}
}
// MARK: - Book Detail
struct BookDetailData {
let book: Book
let chapters: [ChapterIndex]
let previewChapters: [PreviewChapter]?
let inLib: Bool
let saved: Bool
let lastChapter: Int?
}
struct PreviewChapter: Codable, Identifiable {
var id: Int { number }
let number: Int
@@ -192,6 +219,19 @@ struct PreviewChapter: Codable, Identifiable {
let url: String
}
// MARK: - Chapter Content
struct ChapterContent {
let book: BookBrief
let chapter: ChapterIndex
let html: String
let voices: [String]
let prev: Int?
let next: Int?
let chapters: [ChapterIndexBrief]
let isPreview: Bool
}
struct BookBrief: Codable {
let slug: String
let title: String

View File

@@ -63,7 +63,9 @@ actor APIClient {
// paths like /api/chapter/slug/1. URL(string:) preserves slashes correctly.
let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
print("[APIClient] ▶ \(method) \(urlString)")
guard let url = URL(string: urlString) else {
print("[APIClient] ✗ Could not construct URL from: \(urlString)")
throw APIError.invalidResponse
}
var req = URLRequest(url: url)
@@ -73,6 +75,12 @@ actor APIClient {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(body)
}
// Log cookies being sent
if let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty {
print("[APIClient] cookies: \(cookies.map { "\($0.name)=\($0.value.prefix(20))" })")
} else {
print("[APIClient] cookies: (none)")
}
return req
}
@@ -82,15 +90,40 @@ actor APIClient {
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 {
print("[APIClient] ✗ \(path) — not an HTTP response")
throw APIError.invalidResponse
}
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>"
print("[APIClient] ◀ \(http.statusCode) \(path)\(data.count) bytes")
if data.count < 2000 {
print("[APIClient] body: \(rawBody)")
} else {
print("[APIClient] body (first 2000 chars): \(rawBody.prefix(2000))")
}
guard (200..<300).contains(http.statusCode) else {
throw APIError.httpError(http.statusCode, rawBody)
}
do {
return try JSONDecoder.iso8601.decode(T.self, from: data)
let decoded = try JSONDecoder.iso8601.decode(T.self, from: data)
print("[APIClient] ✓ decoded \(T.self) from \(path)")
return decoded
} catch {
print("[APIClient] ✗ decode error for \(T.self) from \(path)")
print("[APIClient] decode error: \(error)")
if let decodingError = error as? DecodingError {
switch decodingError {
case .typeMismatch(let type, let ctx):
print("[APIClient] typeMismatch: expected \(type) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
case .valueNotFound(let type, let ctx):
print("[APIClient] valueNotFound: \(type) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
case .keyNotFound(let key, let ctx):
print("[APIClient] keyNotFound: '\(key.stringValue)' at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
case .dataCorrupted(let ctx):
print("[APIClient] dataCorrupted: \(ctx.debugDescription) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
@unknown default:
print("[APIClient] unknown decoding error: \(error)")
}
}
throw APIError.decodingError(error)
}
}

View File

@@ -3,7 +3,7 @@
{
"color": {
"color-space": "srgb",
"components": { "alpha": "1.000", "blue": "0.040", "green": "0.620", "red": "0.960" }
"components": { "alpha": "1.000", "blue": "0.588", "green": "0.467", "red": "1.000" }
},
"idiom": "universal"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -3,19 +3,6 @@ import AVFoundation
import MediaPlayer
import Combine
// 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
@@ -40,30 +27,12 @@ final class AudioPlayerService: ObservableObject {
@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 currentTime: Double = 0
@Published var duration: Double = 0
@Published var isPlaying: Bool = false
@Published var autoNext: Bool = false
@Published var nextChapter: Int? = nil
@Published var prevChapter: Int? = nil
@Published var sleepTimer: SleepTimerOption? = nil
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
@Published var nextAudioURL: String = ""
@@ -75,20 +44,6 @@ final class AudioPlayerService: ObservableObject {
default: return true
}
}
/// Absolute previous chapter number (current - 1), or nil if at first chapter
var absolutePrevChapter: Int? {
guard chapter > 1 else { return nil }
return chapter - 1
}
/// Absolute next chapter number (current + 1), or nil if at last chapter
var absoluteNextChapter: Int? {
guard !chapters.isEmpty else { return nil }
let maxChapter = chapters.map(\.number).max() ?? chapter
guard chapter < maxChapter else { return nil }
return chapter + 1
}
// MARK: - Private
@@ -105,10 +60,6 @@ final class AudioPlayerService: ObservableObject {
// 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<Void, Never>?
private var sleepTimerStartChapter: Int = 0
// MARK: - Init
@@ -122,7 +73,7 @@ final class AudioPlayerService: ObservableObject {
/// 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?) {
chapters: [ChapterIndexBrief], nextChapter: Int?) {
generationTask?.cancel()
prefetchTask?.cancel()
stop()
@@ -136,15 +87,9 @@ final class AudioPlayerService: ObservableObject {
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
@@ -194,33 +139,6 @@ final class AudioPlayerService: ObservableObject {
if isPlaying { player?.rate = Float(newSpeed) }
updateNowPlaying()
}
func setSleepTimer(_ option: SleepTimerOption?) {
// Cancel existing timer
sleepTimerTask?.cancel()
sleepTimerTask = nil
sleepTimer = option
guard let option else { return }
// Start timer based on option
switch option {
case .chapters(let count):
sleepTimerStartChapter = chapter
// Monitor chapter changes in handlePlaybackFinished
case .minutes(let minutes):
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
}
}
}
}
func stop() {
player?.pause()
@@ -230,11 +148,6 @@ final class AudioPlayerService: ObservableObject {
duration = 0
audioURL = ""
status = .idle
// Cancel sleep timer
sleepTimerTask?.cancel()
sleepTimerTask = nil
sleepTimer = nil
}
// MARK: - Audio generation
@@ -400,15 +313,6 @@ final class AudioPlayerService: ObservableObject {
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
}
}
// Always notify the view that the chapter finished (it may update UI).
NotificationCenter.default.post(
@@ -424,7 +328,6 @@ final class AudioPlayerService: ObservableObject {
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.
@@ -435,18 +338,12 @@ final class AudioPlayerService: ObservableObject {
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)
@@ -463,8 +360,7 @@ final class AudioPlayerService: ObservableObject {
voice: voice,
speed: speed,
chapters: chapters,
nextChapter: nextNextChapter,
prevChapter: nextPrevChapter
nextChapter: nextNextChapter
)
}
}
@@ -510,9 +406,9 @@ final class AudioPlayerService: ObservableObject {
self?.togglePlayPause()
return .success
}
center.skipForwardCommand.preferredIntervals = [15]
center.skipForwardCommand.preferredIntervals = [30]
center.skipForwardCommand.addTarget { [weak self] _ in
self?.skip(by: 15)
self?.skip(by: 30)
return .success
}
center.skipBackwardCommand.preferredIntervals = [15]
@@ -564,13 +460,6 @@ enum AudioPlayerStatus: Equatable {
}
}
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")
}

View File

@@ -50,12 +50,14 @@ final class BrowseViewModel: ObservableObject {
}
private func loadPage(_ page: Int) async {
print("[Browse] loadPage(\(page)) genre=\(genre) sort=\(sort) status=\(status)")
isLoading = true
error = nil
do {
let result = try await APIClient.shared.browse(
page: page, genre: genre, sort: sort, status: status
)
print("[Browse] ✓ page \(page)\(result.novels.count) novels, hasNext=\(result.hasNext)")
if page == 1 {
novels = result.novels
} else {
@@ -65,6 +67,7 @@ final class BrowseViewModel: ObservableObject {
currentPage = page
} catch {
if !(error is CancellationError) {
print("[Browse] ✗ error on page \(page): \(error)")
self.error = error.localizedDescription
}
}

View File

@@ -3,38 +3,34 @@ import Foundation
@MainActor
final class ChapterReaderViewModel: ObservableObject {
let slug: String
private(set) var chapter: Int
let chapter: Int
@Published var content: ChapterResponse?
@Published var isLoading = false
@Published var error: String?
@Published var navigateTo: Int? // set to trigger navigation to next chapter
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 {
print("[ChapterReader] load() slug=\(slug) chapter=\(chapter)")
isLoading = true
error = nil
do {
content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter)
print("[ChapterReader] ✓ loaded chapter \(chapter), html length=\(content?.html.count ?? 0)")
// Record reading progress
try? await APIClient.shared.setProgress(slug: slug, chapter: chapter)
} catch {
if !(error is CancellationError) {
print("[ChapterReader] ✗ error: \(error)")
self.error = error.localizedDescription
}
}
print("[ChapterReader] done — isLoading=false, content=\(content != nil), error=\(String(describing: self.error))")
isLoading = false
}
@@ -51,7 +47,6 @@ final class ChapterReaderViewModel: ObservableObject {
audioPlayer.togglePlayPause()
} else {
let nextChapter: Int? = content.next
let prevChapter: Int? = content.prev
audioPlayer.load(
slug: slug,
chapter: chapter,
@@ -61,8 +56,7 @@ final class ChapterReaderViewModel: ObservableObject {
voice: settings.voice,
speed: settings.speed,
chapters: content.chapters,
nextChapter: nextChapter,
prevChapter: prevChapter
nextChapter: nextChapter
)
}
}

View File

@@ -30,7 +30,9 @@ struct BookDetailView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar { bookmarkButton }
.task { await vm.load() }
.errorAlert($vm.error)
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
Button("OK") { vm.error = nil }
} message: { Text(vm.error ?? "") }
}
// MARK: - Hero
@@ -62,7 +64,7 @@ struct BookDetailView: View {
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
HStack {
// TagChip(label: book.status).colorScheme(.dark)
TagChip(label: book.status).colorScheme(.dark)
ForEach(book.genres.prefix(2), id: \.self) {
TagChip(label: $0).colorScheme(.dark)
}
@@ -84,7 +86,7 @@ struct BookDetailView: View {
Button(summaryExpanded ? "Less" : "More") {
withAnimation { summaryExpanded.toggle() }
}
.font(.caption.bold()) 
.font(.caption.bold())
.foregroundStyle(.amber)
}
}

View File

@@ -90,7 +90,12 @@ struct BrowseView: View {
}
}
.navigationTitle("Discover")
.appNavigationDestination()
.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)
}
}
.sheet(isPresented: $showFilters) {
BrowseFiltersView(vm: vm)
}

View File

@@ -7,9 +7,6 @@ struct ChapterReaderView: View {
let slug: String
let chapterNumber: Int
/// Tracks the currently displayed chapter updated in-place by skip/auto-next
/// so we never accumulate stale listeners on the navigation stack.
@State private var currentChapter: Int
@StateObject private var vm: ChapterReaderViewModel
@EnvironmentObject var audioPlayer: AudioPlayerService
@EnvironmentObject var authStore: AuthStore
@@ -17,17 +14,20 @@ struct ChapterReaderView: View {
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 {
let _ = print("[ChapterReaderView] body eval — slug=\(slug) ch=\(chapterNumber) isLoading=\(vm.isLoading) hasContent=\(vm.content != nil) error=\(vm.error ?? "nil")")
Group {
if vm.isLoading {
let _ = print("[ChapterReaderView] branch: LOADING")
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let content = vm.content {
let _ = print("[ChapterReaderView] branch: CONTENT html=\(content.html.count)chars title='\(content.chapter.title)'")
readerContent(content)
} else if let errMsg = vm.error {
let _ = print("[ChapterReaderView] branch: ERROR '\(errMsg)'")
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
@@ -42,45 +42,33 @@ struct ChapterReaderView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
let _ = print("[ChapterReaderView] branch: BLANK (no loading, no content, no error)")
Color.clear
}
}
.navigationTitle(vm.content.map { "Ch.\($0.chapter.number)" } ?? "")
.navigationBarTitleDisplayMode(.inline)
.overlay(alignment: .bottomTrailing) {
// Floating audio button when player is not active
if !audioPlayer.isActive {
floatingAudioButton
}
}
.task(id: currentChapter) {
.toolbar { audioToolbarButton }
.task {
print("[ChapterReaderView] .task fired — calling vm.load()")
await vm.load()
print("[ChapterReaderView] .task completed")
}
.onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in
guard let next = note.userInfo?["next"] as? Int else { return }
let shouldAutoNavigate = note.userInfo?["autoNext"] as? Bool ?? false
// Only handle if this is the top-most (currently active) chapter view
guard shouldAutoNavigate, currentChapter == audioPlayer.chapter else { return }
navigateToChapter(next)
if shouldAutoNavigate {
vm.navigateTo = next
}
}
.onReceive(NotificationCenter.default.publisher(for: .skipToNextChapter)) { note in
guard let next = note.userInfo?["next"] as? Int else { return }
// Only the view whose chapter matches the currently playing chapter should handle this
guard currentChapter == audioPlayer.chapter else { return }
navigateToChapter(next)
.navigationDestination(isPresented: Binding(
get: { vm.navigateTo != nil },
set: { if !$0 { vm.navigateTo = nil } }
)) {
if let next = vm.navigateTo {
ChapterReaderView(slug: slug, chapterNumber: next)
}
}
.onReceive(NotificationCenter.default.publisher(for: .skipToPrevChapter)) { note in
guard let prev = note.userInfo?["prev"] as? Int else { return }
guard currentChapter == audioPlayer.chapter else { return }
navigateToChapter(prev)
}
}
/// Navigate to a chapter in-place: reloads content without pushing to the navigation stack.
/// Back button always returns to BookDetailView regardless of how many chapters were visited.
private func navigateToChapter(_ chapter: Int) {
vm.switchChapter(to: chapter)
currentChapter = chapter
}
// MARK: - Content
@@ -89,6 +77,7 @@ struct ChapterReaderView: View {
@ViewBuilder
private func readerContent(_ content: ChapterResponse) -> some View {
let _ = print("[ChapterReaderView] readerContent — html=\(content.html.count)chars webHeight=\(webHeight)")
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Header
@@ -112,21 +101,17 @@ struct ChapterReaderView: View {
Divider()
// Prev / Next navigation in-place swap so back button always returns to book
// Prev / Next navigation
HStack(spacing: 12) {
if let prev = content.prev {
Button {
navigateToChapter(prev)
} label: {
NavigationLink(value: NavDestination.chapter(slug, prev)) {
Label("Ch.\(prev)", systemImage: "chevron.left")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
if let next = content.next {
Button {
navigateToChapter(next)
} label: {
NavigationLink(value: NavDestination.chapter(slug, next)) {
Label("Ch.\(next)", systemImage: "chevron.right")
.labelStyle(ReverseLabelStyle())
.frame(maxWidth: .infinity)
@@ -147,31 +132,21 @@ struct ChapterReaderView: View {
}
}
// MARK: - Floating audio button
// MARK: - Audio toolbar button
@ViewBuilder
private var floatingAudioButton: some View {
Button {
vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings)
} label: {
HStack(spacing: 8) {
Image(systemName: "play.circle.fill")
.font(.system(size: 22))
Text("Listen")
.font(.subheadline.weight(.semibold))
@ToolbarContentBuilder
private var audioToolbarButton: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Button {
vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings)
} label: {
Image(systemName: audioPlayer.isActive &&
audioPlayer.slug == slug &&
audioPlayer.chapter == chapterNumber
? "speaker.wave.2.fill" : "speaker.wave.2")
.foregroundStyle(.amber)
}
.foregroundStyle(.white)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(
Capsule()
.fill(Color.amber)
.shadow(color: .black.opacity(0.25), radius: 8, y: 4)
)
}
.buttonStyle(.plain)
.padding(.trailing, 20)
.padding(.bottom, 20)
}
}
@@ -209,6 +184,7 @@ struct HTMLContentView: UIViewRepresentable {
p { margin: 0 0 1em 0; }
"""
let wrapped = "<html><head><style>\(css)</style><meta name='viewport' content='width=device-width, initial-scale=1'></head><body>\(html)</body></html>"
print("[HTMLContentView] updateUIView — html=\(html.count)chars loading into WKWebView")
uiView.loadHTMLString(wrapped, baseURL: nil)
}
@@ -217,12 +193,18 @@ struct HTMLContentView: UIViewRepresentable {
init(_ parent: HTMLContentView) { self.parent = parent }
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
print("[HTMLContentView] didFinish — evaluating scrollHeight")
webView.evaluateJavaScript("document.body.scrollHeight") { result, error in
DispatchQueue.main.async {
print("[HTMLContentView] scrollHeight result=\(String(describing: result)) error=\(String(describing: error))")
if let h = result as? CGFloat, h > 0 {
print("[HTMLContentView] height set to CGFloat \(h)")
self.parent.height = h
} else if let h = result as? Double, h > 0 {
print("[HTMLContentView] height set to Double \(h)")
self.parent.height = CGFloat(h)
} else {
print("[HTMLContentView] ⚠️ could not read height — keeping \(self.parent.height)")
}
}
}

View File

@@ -75,10 +75,19 @@ struct HomeView: View {
.padding(.vertical)
}
.navigationTitle("Home")
.appNavigationDestination()
.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)
}
}
.refreshable { await vm.load() }
.task { await vm.load() }
.errorAlert($vm.error)
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
Button("OK") { vm.error = nil }
} message: {
Text(vm.error ?? "")
}
}
}
}

View File

@@ -34,10 +34,17 @@ struct LibraryView: View {
}
}
.navigationTitle("Library")
.appNavigationDestination()
.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)
}
}
.refreshable { await vm.load() }
.task { await vm.load() }
.errorAlert($vm.error)
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
Button("OK") { vm.error = nil }
} message: { Text(vm.error ?? "") }
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -68,7 +68,9 @@ struct ProfileView: View {
.sheet(isPresented: $showChangePassword) {
ChangePasswordView()
}
.errorAlert($vm.error)
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
Button("OK") { vm.error = nil }
} message: { Text(vm.error ?? "") }
}
}
@@ -119,7 +121,7 @@ struct ProfileView: View {
}
}
),
in: 0.5...2.0, step: 0.25
in: 0.5...3.0, step: 0.25
)
.tint(.amber)
}
@@ -206,8 +208,7 @@ struct ChangePasswordView: View {
body: Body(currentPassword: current, newPassword: newPwd)
)
success = true
try? await Task.sleep(nanoseconds: 1_200_000_000)
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { dismiss() }
} catch {
self.error = error.localizedDescription
}

View File

@@ -1,6 +1,6 @@
name: LibNovel
options:
bundleIdPrefix: com.kalekber
bundleIdPrefix: cc.kalekber
deploymentTarget:
iOS: "17.0"
xcodeVersion: "16.0"
@@ -23,6 +23,10 @@ settings:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: ""
packages:
# Markdown rendering — renders chapter HTML from the server
MarkdownUI:
url: https://github.com/gonzalezreal/swift-markdown-ui
from: "2.4.0"
# Async image loading with caching
Kingfisher:
url: https://github.com/onevcat/Kingfisher
@@ -42,19 +46,14 @@ targets:
- path: LibNovel/Resources/Assets.xcassets
dependencies:
- package: Kingfisher
- package: MarkdownUI
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel
PRODUCT_BUNDLE_IDENTIFIER: cc.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
CODE_SIGN_IDENTITY: "iPhone Distribution"
DEVELOPMENT_TEAM: GHZXC6FVMU
PROVISIONING_PROFILE: $(PROFILE_UUID)
LibNovelTests:
type: bundle.unit-test
@@ -66,7 +65,7 @@ targets:
- target: LibNovel
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel.tests
PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel.tests
schemes:
LibNovel:

View File

@@ -130,18 +130,13 @@ ios-test: ios-gen ios-resolve
# 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 <team-id> <profile-uuid>
ios-archive team_id profile_uuid: ios-gen ios-resolve
ios-archive: 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_STYLE=Manual \
DEVELOPMENT_TEAM="{{team_id}}" \
PROVISIONING_PROFILE="{{profile_uuid}}"
-archivePath {{runner_temp}}/LibNovel.xcarchive
# Export an IPA from the archive produced by ios-archive.
# Requires ios/LibNovel/ExportOptions.plist.
@@ -152,23 +147,6 @@ ios-export:
-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)

View File

@@ -101,7 +101,7 @@ func newE2EFixture(t *testing.T) *e2eFixture {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg, slog.Default())
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg)
if err != nil {
t.Fatalf("NewHybridStore: %v", err)
}

View File

@@ -70,7 +70,7 @@ func newTestStore(t *testing.T) *storage.HybridStore {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg, slog.Default())
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg)
if err != nil {
t.Fatalf("NewHybridStore: %v", err)
}

View File

@@ -1,39 +0,0 @@
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: ""

View File

@@ -1,123 +0,0 @@
#!/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"

View File

@@ -13,6 +13,7 @@ export const GET: RequestHandler = async ({ locals }) => {
return json({
id: locals.user.id,
username: locals.user.username,
role: locals.user.role
role: locals.user.role,
created: locals.user.created ?? ''
});
};

View File

@@ -6,10 +6,6 @@
let { data }: { data: PageData } = $props();
let html = $state(data.html);
let fetchingContent = $state(!data.isPreview && !data.html);
let fetchError = $state('');
// ── Word count ────────────────────────────────────────────────────────────
function countWords(htmlStr: string | null): number {
if (!htmlStr) return 0;
@@ -19,6 +15,10 @@
const wordCount = $derived(countWords(html));
let html = $state(data.html);
let fetchingContent = $state(!data.isPreview && !data.html);
let fetchError = $state('');
onMount(async () => {
// Record reading progress (skip for preview chapters)
if (!data.isPreview) {