diff --git a/AppIcon-dark.png b/AppIcon-dark.png
new file mode 100644
index 0000000..2f6982d
Binary files /dev/null and b/AppIcon-dark.png differ
diff --git a/AppIcon-tinted.png b/AppIcon-tinted.png
new file mode 100644
index 0000000..de5a4c0
Binary files /dev/null and b/AppIcon-tinted.png differ
diff --git a/AppIcon.png b/AppIcon.png
new file mode 100644
index 0000000..3a418a5
Binary files /dev/null and b/AppIcon.png differ
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..d6cbaf0
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,49 @@
+# BookStax – Development Changelog
+
+## Session 1
+
+### Architecture & Foundation
+- Full app architecture planned and implemented from scratch
+- Target: iOS 26.2, Xcode 26, Swift 6, `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`
+- Models: `BookStackError`, all API DTOs (`ShelfDTO`, `BookDTO`, `ChapterDTO`, `PageDTO`, etc.), SwiftData cache models
+- Services: `KeychainService` (actor), `ConnectivityMonitor` (@Observable), `BookStackAPI` (actor), `SyncService`
+- ViewModels: `OnboardingViewModel`, `LibraryViewModel`, `SearchViewModel`, `PageEditorViewModel`
+- Views: Full onboarding flow, library hierarchy, page reader (WebPage/WebView iOS 26), editor, search, settings, main tab view
+- Localisation: English, German, Spanish (`Localizable.strings`)
+- SwiftData model container, onboarding gate, Keychain credential loading wired in `bookstaxApp.swift`
+
+---
+
+## Session 2
+
+### Bug Fixes
+- **BookStackAPI filter syntax**: Fixed `book_id=X` → `filter[book_id]=X` for chapters and pages endpoints (BookStack API requirement)
+- **Color picker in Settings not responding**: Added `.buttonStyle(.plain)` to swatch buttons inside `Form` (SwiftUI list button style conflict)
+
+### Localisation
+- **Search section fully translated**: Replaced all hardcoded English strings in `SearchView.swift` with `L()` keys
+- **`ContentType.displayName`**: Changed from hardcoded English to `NSLocalizedString` (required because `nonisolated` structs can't call `@MainActor`-isolated `L()`)
+- New localisation keys added to en/de/es: `search.loading`, `search.empty.title`, `search.empty.message`, `search.filter.all`, `search.type.page/book/chapter/shelf`
+
+### Breadcrumb Navigation (Library)
+- Added `BookInShelf` wrapper struct (Hashable) to carry shelf context through `NavigationStack`
+- Added `Crumb` struct and `BreadcrumbBar` view (scrollable, tappable ancestor crumbs)
+- Breadcrumbs embedded as first `Section` in `List` (prevents large title collapse that occurred with `.safeAreaInset`)
+- `BooksInShelfView`: shows Library → Shelf crumbs, dismiss action on Library crumb
+- `BookDetailView`: shows Library → Shelf → Book crumbs, new `shelfName: String?` parameter
+- New `navigationDestination(for: BookInShelf.self)` in `LibraryView`
+
+---
+
+## Session 3
+
+### App Icon
+- Created three 1024×1024 app icon variants (light, dark, tinted) using provided artwork
+- **Light** (`AppIcon.png`): isometric purple BookStax books on white background
+- **Dark** (`AppIcon-dark.png`): same books on dark navy background (user-provided AI-generated image)
+- **Tinted** (`AppIcon-tinted.png`): greyscale version of dark icon for iOS tinted icon mode
+- `Contents.json` references all three variants with correct `luminosity` appearance entries
+- Icon files also saved as standalone copies in `bookstax/bookstax/`
+
+### Build Fix
+- Fixed stray `t` character at top of `PageEditorView.swift` causing compile errors (`timport SwiftUI` → `import SwiftUI`)
diff --git a/Info.plist b/Info.plist
new file mode 100644
index 0000000..bb8f04f
--- /dev/null
+++ b/Info.plist
@@ -0,0 +1,53 @@
+
+
+
+
+ CFBundleDisplayName
+ $(PRODUCT_NAME)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSRequiresIPhoneOS
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UILaunchScreen
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+ NSAllowsLocalNetworking
+
+
+
+
diff --git a/bookstax.xcodeproj/project.pbxproj b/bookstax.xcodeproj/project.pbxproj
index 03aea8a..837e3cc 100644
--- a/bookstax.xcodeproj/project.pbxproj
+++ b/bookstax.xcodeproj/project.pbxproj
@@ -257,12 +257,8 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKFHUHT63T;
ENABLE_PREVIEWS = YES;
- GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
- INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
- INFOPLIST_KEY_UILaunchScreen_Generation = YES;
- INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
- INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -289,12 +285,8 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKFHUHT63T;
ENABLE_PREVIEWS = YES;
- GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
- INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
- INFOPLIST_KEY_UILaunchScreen_Generation = YES;
- INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
- INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
diff --git a/bookstax/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png b/bookstax/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png
new file mode 100644
index 0000000..2f6982d
Binary files /dev/null and b/bookstax/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png differ
diff --git a/bookstax/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png b/bookstax/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png
new file mode 100644
index 0000000..de5a4c0
Binary files /dev/null and b/bookstax/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png differ
diff --git a/bookstax/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/bookstax/Assets.xcassets/AppIcon.appiconset/AppIcon.png
new file mode 100644
index 0000000..3a418a5
Binary files /dev/null and b/bookstax/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ
diff --git a/bookstax/Assets.xcassets/AppIcon.appiconset/Contents.json b/bookstax/Assets.xcassets/AppIcon.appiconset/Contents.json
index 2305880..4f4b8fb 100644
--- a/bookstax/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/bookstax/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -1,6 +1,7 @@
{
"images" : [
{
+ "filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
@@ -12,6 +13,7 @@
"value" : "dark"
}
],
+ "filename" : "AppIcon-dark.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
@@ -23,6 +25,7 @@
"value" : "tinted"
}
],
+ "filename" : "AppIcon-tinted.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
diff --git a/bookstax/Extensions/AccentTheme.swift b/bookstax/Extensions/AccentTheme.swift
new file mode 100644
index 0000000..88751fb
--- /dev/null
+++ b/bookstax/Extensions/AccentTheme.swift
@@ -0,0 +1,87 @@
+import SwiftUI
+
+// MARK: - Accent Theme
+
+enum AccentTheme: String, CaseIterable, Identifiable {
+ case ocean = "ocean"
+ case indigo = "indigo"
+ case teal = "teal"
+ case mint = "mint"
+ case sage = "sage"
+ case amber = "amber"
+ case rose = "rose"
+ case graphite = "graphite"
+
+ var id: String { rawValue }
+
+ var displayName: String {
+ switch self {
+ case .ocean: return "Ocean"
+ case .indigo: return "Indigo"
+ case .teal: return "Teal"
+ case .mint: return "Mint"
+ case .sage: return "Sage"
+ case .amber: return "Amber"
+ case .rose: return "Rose"
+ case .graphite: return "Graphite"
+ }
+ }
+
+ /// Primary accent — used for shelves, primary buttons, active states
+ var shelfColor: Color {
+ switch self {
+ case .ocean: return .blue
+ case .indigo: return .indigo
+ case .teal: return .teal
+ case .mint: return .mint
+ case .sage: return Color(red: 0.369, green: 0.545, blue: 0.431)
+ case .amber: return .orange
+ case .rose: return .pink
+ case .graphite: return Color(red: 0.420, green: 0.447, blue: 0.502)
+ }
+ }
+
+ /// Secondary — used for books
+ var bookColor: Color {
+ switch self {
+ case .ocean: return .cyan
+ case .indigo: return .purple
+ case .teal: return .mint
+ case .mint: return .teal
+ case .sage: return Color(red: 0.290, green: 0.478, blue: 0.541)
+ case .amber: return Color(red: 0.816, green: 0.624, blue: 0.173)
+ case .rose: return .red
+ case .graphite: return Color(red: 0.545, green: 0.584, blue: 0.631)
+ }
+ }
+
+ /// Tertiary — used for pages
+ var pageColor: Color {
+ switch self {
+ case .ocean: return Color(red: 0.180, green: 0.639, blue: 0.659) // custom teal-green
+ case .indigo: return .blue
+ case .teal: return .cyan
+ case .mint: return .green
+ case .sage: return Color(red: 0.420, green: 0.557, blue: 0.369)
+ case .amber: return Color(red: 0.910, green: 0.576, blue: 0.353)
+ case .rose: return .orange
+ case .graphite: return Color(red: 0.612, green: 0.639, blue: 0.671)
+ }
+ }
+
+ /// The single tint used for navigation bars, links, and interactive elements
+ var accentColor: Color { shelfColor }
+}
+
+// MARK: - Environment Key
+
+private struct AccentThemeKey: EnvironmentKey {
+ static let defaultValue: AccentTheme = .ocean
+}
+
+extension EnvironmentValues {
+ var accentTheme: AccentTheme {
+ get { self[AccentThemeKey.self] }
+ set { self[AccentThemeKey.self] = newValue }
+ }
+}
diff --git a/bookstax/Models/APIError.swift b/bookstax/Models/APIError.swift
index 85333cc..34a0a5f 100644
--- a/bookstax/Models/APIError.swift
+++ b/bookstax/Models/APIError.swift
@@ -25,7 +25,7 @@ enum BookStackError: LocalizedError, Sendable {
case .unauthorized:
return "Invalid Token ID or Secret. Double-check both values — the secret is only shown once in BookStack."
case .forbidden:
- return "Your account doesn't have API access. Ask your BookStack administrator to enable the \"Access System API\" permission for your role."
+ return "Access denied. Either your account lacks the \"Access System API\" role permission, or your reverse proxy (nginx/Caddy) is not forwarding the Authorization header. Add `proxy_set_header Authorization $http_authorization;` to your proxy config."
case .notFound(let resource):
return "\(resource) could not be found. It may have been deleted or moved."
case .httpError(let code, let message):
diff --git a/bookstax/Models/DTOs.swift b/bookstax/Models/DTOs.swift
index 10e8d99..6be3fdd 100644
--- a/bookstax/Models/DTOs.swift
+++ b/bookstax/Models/DTOs.swift
@@ -115,10 +115,10 @@ nonisolated struct SearchResultDTO: Codable, Sendable, Identifiable, Hashable {
var displayName: String {
switch self {
- case .page: return "Pages"
- case .book: return "Books"
- case .chapter: return "Chapters"
- case .shelf: return "Shelves"
+ case .page: return NSLocalizedString("search.type.page", comment: "")
+ case .book: return NSLocalizedString("search.type.book", comment: "")
+ case .chapter: return NSLocalizedString("search.type.chapter", comment: "")
+ case .shelf: return NSLocalizedString("search.type.shelf", comment: "")
}
}
@@ -169,15 +169,19 @@ nonisolated struct UserSummaryDTO: Codable, Sendable, Hashable {
}
}
-// MARK: - API Info
+// MARK: - API Info (from /api/system)
nonisolated struct APIInfo: Codable, Sendable {
let version: String
let appName: String?
+ let instanceId: String?
+ let baseUrl: String?
enum CodingKeys: String, CodingKey {
case version
case appName = "app_name"
+ case instanceId = "instance_id"
+ case baseUrl = "base_url"
}
}
@@ -194,3 +198,17 @@ nonisolated struct UserDTO: Codable, Sendable {
case avatarUrl = "avatar_url"
}
}
+
+// MARK: - Image Gallery
+
+nonisolated struct ImageUploadResponse: Codable, Sendable {
+ let id: Int
+ let name: String
+ let url: String
+ let content: ImageContent?
+
+ nonisolated struct ImageContent: Codable, Sendable {
+ let html: String?
+ let markdown: String?
+ }
+}
diff --git a/bookstax/Services/BookStackAPI.swift b/bookstax/Services/BookStackAPI.swift
index 63ba9ba..579567b 100644
--- a/bookstax/Services/BookStackAPI.swift
+++ b/bookstax/Services/BookStackAPI.swift
@@ -21,10 +21,12 @@ actor BookStackAPI {
// MARK: - Configuration
func configure(serverURL: String, tokenId: String, tokenSecret: String) {
- self.serverURL = serverURL
- self.tokenId = tokenId
- self.tokenSecret = tokenSecret
- AppLog(.info, "API configured for \(serverURL)", category: "API")
+ var clean = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
+ while clean.hasSuffix("/") { clean = String(clean.dropLast()) }
+ self.serverURL = clean
+ self.tokenId = tokenId.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.tokenSecret = tokenSecret.trimmingCharacters(in: .whitespacesAndNewlines)
+ AppLog(.info, "API configured for \(clean)", category: "API")
}
func getServerURL() -> String { serverURL }
@@ -88,6 +90,8 @@ actor BookStackAPI {
mapped = .timeout
case .notConnectedToInternet, .networkConnectionLost:
mapped = .networkUnavailable
+ case .cannotFindHost, .dnsLookupFailed:
+ mapped = .notReachable(host: serverURL)
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
mapped = .sslError
@@ -214,7 +218,7 @@ actor BookStackAPI {
func fetchChapters(bookId: Int) async throws -> [ChapterDTO] {
let response: PaginatedResponse = try await request(
- endpoint: "chapters?book_id=\(bookId)&count=100&sort=+priority"
+ endpoint: "chapters?filter[book_id]=\(bookId)&count=100&sort=+priority"
)
return response.data
}
@@ -241,7 +245,7 @@ actor BookStackAPI {
func fetchPages(bookId: Int) async throws -> [PageDTO] {
let response: PaginatedResponse = try await request(
- endpoint: "pages?book_id=\(bookId)&count=100&sort=+priority"
+ endpoint: "pages?filter[book_id]=\(bookId)&count=100&sort=+priority"
)
return response.data
}
@@ -319,16 +323,95 @@ actor BookStackAPI {
// MARK: - System Info & Users
- /// Phase 1: Check the server is reachable and is a BookStack instance.
- /// Uses a plain URLSession request (no auth header) so it works before credentials are set.
- func verifyServerReachable(url: String) async throws -> APIInfo {
- AppLog(.info, "Verifying server reachability: \(url)", category: "Auth")
- guard let requestURL = URL(string: "\(url)/api/info") else {
- AppLog(.error, "Invalid server URL: \(url)", category: "Auth")
+ /// Verify that the given URL is a reachable BookStack instance AND that the
+ /// supplied token is valid — all in one request to GET /api/system.
+ ///
+ /// /api/system requires authentication and returns instance info on success:
+ /// 200 → server reachable + token valid → returns APIInfo
+ /// 401 → server reachable but token invalid
+ /// 403 → server reachable but account lacks API permission
+ /// 5xx / network error → server unreachable
+ func verifyConnection(url: String, tokenId: String, tokenSecret: String) async throws -> APIInfo {
+ // Normalise defensively in case caller skipped validateServerURL
+ var cleanURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
+ while cleanURL.hasSuffix("/") { cleanURL = String(cleanURL.dropLast()) }
+
+ AppLog(.info, "Verifying connection to \(cleanURL)", category: "Auth")
+ guard let requestURL = URL(string: "\(cleanURL)/api/system") else {
+ AppLog(.error, "Invalid server URL: \(cleanURL)", category: "Auth")
+ throw BookStackError.invalidURL
+ }
+
+ var req = URLRequest(url: requestURL)
+ req.httpMethod = "GET"
+ req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
+ req.setValue("application/json", forHTTPHeaderField: "Accept")
+ req.timeoutInterval = 15
+
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await URLSession.shared.data(for: req)
+ } catch let urlError as URLError {
+ AppLog(.error, "Network error reaching \(url): \(urlError.localizedDescription)", category: "Auth")
+ switch urlError.code {
+ case .timedOut:
+ throw BookStackError.timeout
+ case .notConnectedToInternet, .networkConnectionLost:
+ throw BookStackError.networkUnavailable
+ case .serverCertificateUntrusted, .serverCertificateHasBadDate,
+ .serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
+ throw BookStackError.sslError
+ default:
+ throw BookStackError.notReachable(host: url)
+ }
+ }
+
+ guard let http = response as? HTTPURLResponse else {
+ throw BookStackError.notReachable(host: url)
+ }
+
+ AppLog(.debug, "GET /api/system → HTTP \(http.statusCode)", category: "Auth")
+
+ switch http.statusCode {
+ case 200:
+ // Decode the system info
+ if let info = try? decoder.decode(APIInfo.self, from: data) {
+ AppLog(.info, "Connected to BookStack \(info.version) — \(info.appName ?? "unknown")", category: "Auth")
+ return info
+ }
+ // Unexpected body but 200 — treat as success with unknown version
+ AppLog(.warning, "GET /api/system returned 200 but unexpected body", category: "Auth")
+ return APIInfo(version: "unknown", appName: nil, instanceId: nil, baseUrl: nil)
+
+ case 401:
+ throw BookStackError.unauthorized
+
+ case 403:
+ let msg = parseErrorMessage(from: data)
+ AppLog(.error, "GET /api/system → 403: \(msg ?? "forbidden")", category: "Auth")
+ throw BookStackError.forbidden
+
+ case 404:
+ // Old BookStack version without /api/system — fall back to /api/books probe
+ AppLog(.warning, "/api/system not found (older BookStack?), falling back to /api/books", category: "Auth")
+ return try await verifyViaBooks(url: url, tokenId: tokenId, tokenSecret: tokenSecret)
+
+ case 500...:
+ throw BookStackError.notReachable(host: url)
+
+ default:
+ throw BookStackError.httpError(statusCode: http.statusCode, message: nil)
+ }
+ }
+
+ /// Fallback for older BookStack instances that don't have /api/system.
+ private func verifyViaBooks(url: String, tokenId: String, tokenSecret: String) async throws -> APIInfo {
+ guard let requestURL = URL(string: "\(url)/api/books?count=1") else {
throw BookStackError.invalidURL
}
var req = URLRequest(url: requestURL)
req.httpMethod = "GET"
+ req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.timeoutInterval = 15
@@ -342,8 +425,6 @@ actor BookStackAPI {
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
throw BookStackError.sslError
- case .cannotConnectToHost, .cannotFindHost:
- throw BookStackError.notReachable(host: url)
default: throw BookStackError.notReachable(host: url)
}
}
@@ -352,35 +433,16 @@ actor BookStackAPI {
throw BookStackError.notReachable(host: url)
}
- // Server is completely unreachable (5xx or no response)
- guard http.statusCode < 500 else {
- throw BookStackError.notReachable(host: url)
+ switch http.statusCode {
+ case 200:
+ AppLog(.info, "Fallback /api/books succeeded — BookStack confirmed", category: "Auth")
+ return APIInfo(version: "unknown", appName: nil, instanceId: nil, baseUrl: nil)
+ case 401: throw BookStackError.unauthorized
+ case 403: throw BookStackError.forbidden
+ default:
+ let body = String(data: data, encoding: .utf8) ?? ""
+ throw BookStackError.notReachable(host: "\(url) (HTTP \(http.statusCode): \(body.prefix(200)))")
}
-
- // Best case: /api/info returned valid JSON — decode and return
- if let info = try? decoder.decode(APIInfo.self, from: data) {
- AppLog(.info, "Server identified as BookStack \(info.version) (\(info.appName))", category: "Auth")
- return info
- }
-
- // /api/info returned HTML (common when API access is restricted or endpoint is 404'd).
- // Check if the HTML looks like it came from BookStack by looking for its meta tag.
- let body = String(data: data, encoding: .utf8) ?? ""
- if body.contains("meta name=\"base-url\"") || body.contains("BookStack") {
- AppLog(.info, "Server identified as BookStack via HTML fingerprint", category: "Auth")
- return APIInfo(version: "unknown", appName: "BookStack")
- }
-
- AppLog(.error, "Server at \(url) does not appear to be BookStack", category: "Auth")
- throw BookStackError.notBookStack(host: url)
- }
-
- /// Phase 2: Verify the token works by calling an authenticated endpoint.
- func verifyToken() async throws {
- AppLog(.info, "Verifying API token", category: "Auth")
- // GET /api/books?count=1 requires auth — 401 = bad token, 403 = no API permission
- let _: PaginatedResponse = try await request(endpoint: "books?count=1")
- AppLog(.info, "API token verified successfully", category: "Auth")
}
@@ -397,6 +459,70 @@ actor BookStackAPI {
}
return user
}
+
+ // MARK: - Image Gallery
+
+ /// Upload an image to the BookStack image gallery.
+ /// - Parameters:
+ /// - data: Raw image bytes (JPEG or PNG)
+ /// - filename: Filename including extension, e.g. "photo.jpg"
+ /// - mimeType: e.g. "image/jpeg" or "image/png"
+ /// - pageId: The page this image belongs to. Use 0 for new pages not yet saved.
+ func uploadImage(data: Data, filename: String, mimeType: String, pageId: Int) async throws -> ImageUploadResponse {
+ guard !serverURL.isEmpty else { throw BookStackError.notAuthenticated }
+ guard let url = URL(string: "\(serverURL)/api/image-gallery") else { throw BookStackError.invalidURL }
+
+ let boundary = "Boundary-\(UUID().uuidString)"
+ var body = Data()
+
+ func appendField(_ name: String, _ value: String) {
+ body.append("--\(boundary)\r\n".data(using: .utf8)!)
+ body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!)
+ body.append("\(value)\r\n".data(using: .utf8)!)
+ }
+
+ appendField("type", "gallery")
+ appendField("uploaded_to", "\(pageId)")
+ appendField("name", filename)
+
+ // File field
+ body.append("--\(boundary)\r\n".data(using: .utf8)!)
+ body.append("Content-Disposition: form-data; name=\"image\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
+ body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
+ body.append(data)
+ body.append("\r\n".data(using: .utf8)!)
+ body.append("--\(boundary)--\r\n".data(using: .utf8)!)
+
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
+ req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ req.setValue("application/json", forHTTPHeaderField: "Accept")
+ req.httpBody = body
+ req.timeoutInterval = 60
+
+ AppLog(.info, "Uploading image '\(filename)' (\(data.count) bytes) to page \(pageId)", category: "API")
+
+ let (responseData, response): (Data, URLResponse)
+ do {
+ (responseData, response) = try await URLSession.shared.data(for: req)
+ } catch let urlError as URLError {
+ throw BookStackError.unknown(urlError.localizedDescription)
+ }
+
+ guard let http = response as? HTTPURLResponse else {
+ throw BookStackError.unknown("Invalid response")
+ }
+
+ AppLog(.debug, "POST /api/image-gallery → HTTP \(http.statusCode)", category: "API")
+
+ guard http.statusCode == 200 else {
+ let msg = parseErrorMessage(from: responseData)
+ throw BookStackError.httpError(statusCode: http.statusCode, message: msg)
+ }
+
+ return try decoder.decode(ImageUploadResponse.self, from: responseData)
+ }
}
// MARK: - Helper
diff --git a/bookstax/ViewModels/LibraryViewModel.swift b/bookstax/ViewModels/LibraryViewModel.swift
index 8bb6a15..a2d82d9 100644
--- a/bookstax/ViewModels/LibraryViewModel.swift
+++ b/bookstax/ViewModels/LibraryViewModel.swift
@@ -20,6 +20,9 @@ final class LibraryViewModel {
func loadShelves() async {
isLoadingShelves = true
error = nil
+ books = []
+ chapters = []
+ pages = []
AppLog(.info, "Loading shelves", category: "Library")
do {
shelves = try await BookStackAPI.shared.fetchShelves()
@@ -39,6 +42,9 @@ final class LibraryViewModel {
func loadBooks() async {
isLoadingBooks = true
error = nil
+ shelves = []
+ chapters = []
+ pages = []
do {
books = try await BookStackAPI.shared.fetchBooks()
} catch let e as BookStackError {
@@ -52,6 +58,9 @@ final class LibraryViewModel {
func loadBooksForShelf(shelfId: Int) async {
isLoadingBooks = true
error = nil
+ shelves = []
+ chapters = []
+ pages = []
do {
let shelfDetail = try await BookStackAPI.shared.fetchShelf(id: shelfId)
books = shelfDetail.books
@@ -68,6 +77,8 @@ final class LibraryViewModel {
func loadChaptersAndPages(bookId: Int) async {
isLoadingContent = true
error = nil
+ shelves = []
+ books = []
AppLog(.info, "Loading chapters and pages for book \(bookId)", category: "Library")
do {
async let chaptersTask = BookStackAPI.shared.fetchChapters(bookId: bookId)
diff --git a/bookstax/ViewModels/OnboardingViewModel.swift b/bookstax/ViewModels/OnboardingViewModel.swift
index c69c370..07110c8 100644
--- a/bookstax/ViewModels/OnboardingViewModel.swift
+++ b/bookstax/ViewModels/OnboardingViewModel.swift
@@ -1,23 +1,22 @@
import Foundation
+import SwiftUI
import Observation
@Observable
final class OnboardingViewModel {
- enum Step: Int, CaseIterable {
+ enum Step: Int, CaseIterable, Hashable {
case language = 0
case welcome = 1
- case serverURL = 2
- case apiToken = 3
- case verify = 4
- case ready = 5
+ case connect = 2
+ case ready = 3
}
- // Navigation
- var currentStep: Step = .welcome
+ // Navigation — NavigationStack path (language is the root, not in the path)
+ var navPath: NavigationPath = NavigationPath()
// Input
- var serverURLInput: String = "https://bs-test.hanold.online"
+ var serverURLInput: String = ""
var tokenIdInput: String = ""
var tokenSecretInput: String = ""
@@ -35,41 +34,16 @@ final class OnboardingViewModel {
}
var verifyPhase: VerifyPhase = .idle
- // Backwards-compat helpers used by VerifyStepView
- var isVerifying: Bool {
- switch verifyPhase {
- case .checkingServer, .checkingToken: return true
- default: return false
- }
- }
- var verificationError: BookStackError? {
- if case .failed(_, let error) = verifyPhase { return error }
- return nil
- }
- var verifiedAppName: String? {
- switch verifyPhase {
- case .serverOK(let n), .done(let n, _): return n
- default: return nil
- }
- }
- var verifiedUserName: String? {
- if case .done(_, let u) = verifyPhase { return u }
- return nil
- }
-
// Completion
var isComplete: Bool = false
// MARK: - Navigation
- func advance() {
- guard let next = Step(rawValue: currentStep.rawValue + 1) else { return }
- currentStep = next
+ func push(_ step: Step) {
+ navPath.append(step)
}
- func goBack() {
- guard let prev = Step(rawValue: currentStep.rawValue - 1) else { return }
- currentStep = prev
+ func resetVerification() {
verifyPhase = .idle
}
@@ -112,40 +86,42 @@ final class OnboardingViewModel {
AppLog(.info, "Starting onboarding verification for \(url)", category: "Onboarding")
- // Phase 1: check the server is reachable and is BookStack
+ // Phase 1: show "reaching server" while we attempt the connection
verifyPhase = .checkingServer
+
let info: APIInfo
do {
- info = try await BookStackAPI.shared.verifyServerReachable(url: url)
+ // Single request to /api/system covers both server reachability and token validity
+ info = try await BookStackAPI.shared.verifyConnection(url: url, tokenId: tokenId, tokenSecret: tokenSecret)
} catch let error as BookStackError {
- AppLog(.error, "Server check failed: \(error.localizedDescription)", category: "Onboarding")
- verifyPhase = .failed(phase: "server", error: error)
+ // Distinguish server-unreachable errors from auth errors for the UI
+ let phase: String
+ switch error {
+ case .unauthorized, .forbidden:
+ phase = "token"
+ case .httpError(let code, _) where code == 403:
+ phase = "token"
+ default:
+ phase = "server"
+ }
+ AppLog(.error, "Verification failed (\(phase)): \(error.localizedDescription)", category: "Onboarding")
+ verifyPhase = .failed(phase: phase, error: error)
return
} catch {
- AppLog(.error, "Server check failed: \(error.localizedDescription)", category: "Onboarding")
+ AppLog(.error, "Verification failed: \(error.localizedDescription)", category: "Onboarding")
verifyPhase = .failed(phase: "server", error: .unknown(error.localizedDescription))
return
}
- let appName = info.appName ?? "BookStack \(info.version)"
+ // Both server and token confirmed — show intermediate state briefly
+ let appName = info.appName ?? "BookStack"
verifyPhase = .serverOK(appName: appName)
- // Phase 2: configure credentials and verify the token
+ // Configure the shared API client with validated credentials
await BookStackAPI.shared.configure(serverURL: url, tokenId: tokenId, tokenSecret: tokenSecret)
verifyPhase = .checkingToken
- do {
- try await BookStackAPI.shared.verifyToken()
- } catch let error as BookStackError {
- AppLog(.error, "Token verification failed: \(error.localizedDescription)", category: "Onboarding")
- verifyPhase = .failed(phase: "token", error: error)
- return
- } catch {
- AppLog(.error, "Token verification failed: \(error.localizedDescription)", category: "Onboarding")
- verifyPhase = .failed(phase: "token", error: .unknown(error.localizedDescription))
- return
- }
- // Attempt to fetch user info (non-fatal)
+ // Attempt to fetch user info (non-fatal — some installs restrict /api/users)
let userName = try? await BookStackAPI.shared.fetchCurrentUser().name
// Persist server URL and credentials
@@ -164,7 +140,9 @@ final class OnboardingViewModel {
AppLog(.info, "Onboarding complete — connected to \(appName)\(userName.map { " as \($0)" } ?? "")", category: "Onboarding")
verifyPhase = .done(appName: appName, userName: userName)
- advance() // → .ready
+
+ // Navigate to the ready step
+ navPath.append(Step.ready)
}
// MARK: - Complete
diff --git a/bookstax/ViewModels/PageEditorViewModel.swift b/bookstax/ViewModels/PageEditorViewModel.swift
index 8a17c1a..e78bc28 100644
--- a/bookstax/ViewModels/PageEditorViewModel.swift
+++ b/bookstax/ViewModels/PageEditorViewModel.swift
@@ -13,6 +13,12 @@ final class PageEditorViewModel {
case write, preview
}
+ enum ImageUploadState {
+ case idle
+ case uploading
+ case failed(String)
+ }
+
let mode: Mode
var title: String = ""
var markdownContent: String = ""
@@ -22,6 +28,10 @@ final class PageEditorViewModel {
var saveError: BookStackError? = nil
var savedPage: PageDTO? = nil
+ var imageUploadState: ImageUploadState = .idle
+ /// Set by the view after a successful upload so the markdown can be inserted at the cursor
+ var pendingImageMarkdown: String? = nil
+
var hasUnsavedChanges: Bool {
switch mode {
case .create:
@@ -76,4 +86,34 @@ final class PageEditorViewModel {
isSaving = false
}
+
+ // MARK: - Image Upload
+
+ /// The page ID to use when uploading images.
+ /// For new pages, we use 0 (BookStack accepts unattached images).
+ var uploadTargetPageId: Int {
+ if case .edit(let page) = mode { return page.id }
+ return 0
+ }
+
+ func uploadImage(data: Data, filename: String, mimeType: String) async {
+ imageUploadState = .uploading
+ do {
+ let result = try await BookStackAPI.shared.uploadImage(
+ data: data,
+ filename: filename,
+ mimeType: mimeType,
+ pageId: uploadTargetPageId
+ )
+ // Use the markdown embed string from the API response, or build one from the URL
+ let markdown = result.content?.markdown ?? ")"
+ pendingImageMarkdown = markdown
+ imageUploadState = .idle
+ AppLog(.info, "Image '\(filename)' uploaded successfully", category: "Editor")
+ } catch {
+ let msg = (error as? BookStackError)?.localizedDescription ?? error.localizedDescription
+ AppLog(.error, "Image upload failed: \(msg)", category: "Editor")
+ imageUploadState = .failed(msg)
+ }
+ }
}
diff --git a/bookstax/Views/Editor/PageEditorView.swift b/bookstax/Views/Editor/PageEditorView.swift
index 55a8759..fa9afe6 100644
--- a/bookstax/Views/Editor/PageEditorView.swift
+++ b/bookstax/Views/Editor/PageEditorView.swift
@@ -1,6 +1,7 @@
import SwiftUI
import UIKit
import WebKit
+import PhotosUI
// MARK: - UITextView wrapper that exposes selection-aware formatting
@@ -65,6 +66,7 @@ struct PageEditorView: View {
@State private var showDiscardAlert = false
/// Reference to the underlying UITextView for formatting operations
@State private var textView: UITextView? = nil
+ @State private var imagePickerItem: PhotosPickerItem? = nil
init(mode: PageEditorViewModel.Mode) {
_viewModel = State(initialValue: PageEditorViewModel(mode: mode))
@@ -73,23 +75,18 @@ struct PageEditorView: View {
var body: some View {
NavigationStack {
VStack(spacing: 0) {
- // Title field
- TextField(L("editor.title.placeholder"), text: $viewModel.title)
- .font(.title2.bold())
- .padding(.horizontal)
- .padding(.vertical, 12)
+ // Title field — prominent, borderless, single bottom rule
+ VStack(spacing: 0) {
+ TextField(L("editor.title.placeholder"), text: $viewModel.title)
+ .font(.system(size: 22, weight: .bold))
+ .padding(.horizontal, 16)
+ .padding(.top, 14)
+ .padding(.bottom, 10)
- Divider()
-
- // Write / Preview toggle
- Picker("", selection: $viewModel.activeTab) {
- Text(L("editor.tab.write")).tag(PageEditorViewModel.EditorTab.write)
- Text(L("editor.tab.preview")).tag(PageEditorViewModel.EditorTab.preview)
+ Rectangle()
+ .fill(Color(.separator))
+ .frame(height: 0.5)
}
- .pickerStyle(.segmented)
- .padding()
-
- Divider()
// Content area
if viewModel.activeTab == .write {
@@ -98,8 +95,44 @@ struct PageEditorView: View {
textView = tv
}
+ // Image upload progress
+ if case .uploading = viewModel.imageUploadState {
+ HStack(spacing: 8) {
+ ProgressView().controlSize(.small)
+ Text(L("editor.image.uploading"))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 6)
+ .background(Color(.secondarySystemBackground))
+ }
+
+ if case .failed(let msg) = viewModel.imageUploadState {
+ HStack(spacing: 8) {
+ Image(systemName: "exclamationmark.circle.fill")
+ .foregroundStyle(.red)
+ Text(msg)
+ .font(.footnote)
+ .foregroundStyle(.red)
+ Spacer()
+ Button { viewModel.imageUploadState = .idle } label: {
+ Image(systemName: "xmark").font(.footnote)
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(Color(.secondarySystemBackground))
+ }
+
Divider()
- FormattingToolbar { action in
+ FormattingToolbar(
+ imagePickerItem: $imagePickerItem,
+ isUploadingImage: {
+ if case .uploading = viewModel.imageUploadState { return true }
+ return false
+ }()
+ ) { action in
applyFormat(action)
}
}
@@ -127,6 +160,10 @@ struct PageEditorView: View {
}
}
}
+ // Write / Preview toggle lives in the nav bar
+ ToolbarItem(placement: .principal) {
+ EditorTabToggle(activeTab: $viewModel.activeTab)
+ }
ToolbarItem(placement: .confirmationAction) {
Button(L("editor.save")) {
Task {
@@ -150,6 +187,40 @@ struct PageEditorView: View {
} message: {
Text(L("editor.discard.message"))
}
+ // Handle image picked from photo library
+ .onChange(of: imagePickerItem) { _, newItem in
+ guard let newItem else { return }
+ Task {
+ guard let data = try? await newItem.loadTransferable(type: Data.self) else { return }
+ let mimeType: String
+ let filename: String
+ if let uti = newItem.supportedContentTypes.first {
+ if uti.conforms(to: .png) {
+ mimeType = "image/png"; filename = "image.png"
+ } else if uti.conforms(to: .webP) {
+ mimeType = "image/webp"; filename = "image.webp"
+ } else {
+ mimeType = "image/jpeg"; filename = "image.jpg"
+ }
+ } else {
+ mimeType = "image/jpeg"; filename = "image.jpg"
+ }
+ await viewModel.uploadImage(data: data, filename: filename, mimeType: mimeType)
+ imagePickerItem = nil
+ }
+ }
+ // When upload completes, insert markdown at cursor
+ .onChange(of: viewModel.pendingImageMarkdown) { _, markdown in
+ guard let markdown else { return }
+ viewModel.pendingImageMarkdown = nil
+ guard let tv = textView else {
+ viewModel.markdownContent += "\n\(markdown)"
+ return
+ }
+ let range = tv.selectedRange
+ let insertion = "\n\(markdown)\n"
+ replace(in: tv, range: range, with: insertion, cursorOffset: insertion.count)
+ }
}
}
@@ -256,6 +327,40 @@ struct PageEditorView: View {
}
}
+// MARK: - Write / Preview Tab Toggle
+
+struct EditorTabToggle: View {
+ @Binding var activeTab: PageEditorViewModel.EditorTab
+
+ var body: some View {
+ HStack(spacing: 0) {
+ tabButton(L("editor.tab.write"), tab: .write)
+ tabButton(L("editor.tab.preview"), tab: .preview)
+ }
+ .background(Color(.secondarySystemBackground), in: Capsule())
+ .padding(.vertical, 4)
+ }
+
+ @ViewBuilder
+ private func tabButton(_ label: String, tab: PageEditorViewModel.EditorTab) -> some View {
+ let isSelected = activeTab == tab
+ Text(label)
+ .font(.system(size: 13, weight: isSelected ? .semibold : .regular))
+ .foregroundStyle(isSelected ? .primary : .secondary)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 6)
+ .background(
+ isSelected
+ ? Color(.systemBackground)
+ : Color.clear,
+ in: Capsule()
+ )
+ .padding(3)
+ .animation(.easeInOut(duration: 0.18), value: activeTab)
+ .onTapGesture { activeTab = tab }
+ }
+}
+
// MARK: - Format Actions
enum FormatAction {
@@ -300,42 +405,74 @@ enum FormatAction {
// MARK: - Formatting Toolbar
struct FormattingToolbar: View {
+ @Binding var imagePickerItem: PhotosPickerItem?
+ let isUploadingImage: Bool
let onAction: (FormatAction) -> Void
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 2) {
- Group {
- FormatButton("H1", action: .h1, onAction: onAction)
- FormatButton("H2", action: .h2, onAction: onAction)
- FormatButton("H3", action: .h3, onAction: onAction)
- toolbarDivider
- FormatButton(systemImage: "bold", action: .bold, onAction: onAction)
- FormatButton(systemImage: "italic", action: .italic, onAction: onAction)
- FormatButton(systemImage: "strikethrough", action: .strikethrough, onAction: onAction)
- FormatButton(systemImage: "chevron.left.forwardslash.chevron.right", action: .inlineCode, onAction: onAction)
- }
+ HStack(spacing: 6) {
+ FormatButton("H1", action: .h1, onAction: onAction)
+ FormatButton("H2", action: .h2, onAction: onAction)
+ FormatButton("H3", action: .h3, onAction: onAction)
+
toolbarDivider
- Group {
- FormatButton(systemImage: "list.bullet", action: .bulletList, onAction: onAction)
- FormatButton(systemImage: "list.number", action: .numberedList, onAction: onAction)
- FormatButton(systemImage: "text.quote", action: .blockquote, onAction: onAction)
- toolbarDivider
- FormatButton(systemImage: "link", action: .link, onAction: onAction)
- FormatButton(systemImage: "curlybraces", action: .codeBlock, onAction: onAction)
- FormatButton(systemImage: "minus", action: .horizontalRule, onAction: onAction)
+
+ FormatButton(systemImage: "bold", action: .bold, onAction: onAction)
+ FormatButton(systemImage: "italic", action: .italic, onAction: onAction)
+ FormatButton(systemImage: "strikethrough", action: .strikethrough, onAction: onAction)
+ FormatButton(systemImage: "chevron.left.forwardslash.chevron.right", action: .inlineCode, onAction: onAction)
+
+ toolbarDivider
+
+ FormatButton(systemImage: "list.bullet", action: .bulletList, onAction: onAction)
+ FormatButton(systemImage: "list.number", action: .numberedList, onAction: onAction)
+ FormatButton(systemImage: "text.quote", action: .blockquote, onAction: onAction)
+
+ toolbarDivider
+
+ FormatButton(systemImage: "link", action: .link, onAction: onAction)
+ FormatButton(systemImage: "curlybraces", action: .codeBlock, onAction: onAction)
+ FormatButton(systemImage: "minus", action: .horizontalRule, onAction: onAction)
+
+ toolbarDivider
+
+ // Image picker button
+ PhotosPicker(
+ selection: $imagePickerItem,
+ matching: .images,
+ photoLibrary: .shared()
+ ) {
+ Group {
+ if isUploadingImage {
+ ProgressView().controlSize(.small)
+ } else {
+ Image(systemName: "photo")
+ .font(.system(size: 15, weight: .medium))
+ }
+ }
+ .frame(width: 40, height: 36)
+ .foregroundStyle(.secondary)
+ .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 9))
}
+ .disabled(isUploadingImage)
}
- .padding(.horizontal, 8)
- .padding(.vertical, 6)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ }
+ .background(Color(.systemBackground))
+ .overlay(alignment: .top) {
+ Rectangle()
+ .fill(Color(.separator))
+ .frame(height: 0.5)
}
- .background(Color(.secondarySystemBackground))
}
private var toolbarDivider: some View {
- Divider()
- .frame(height: 20)
- .padding(.horizontal, 4)
+ Rectangle()
+ .fill(Color(.separator))
+ .frame(width: 0.5, height: 22)
+ .padding(.horizontal, 2)
}
}
@@ -368,12 +505,12 @@ struct FormatButton: View {
.font(.system(size: 13, weight: .semibold, design: .rounded))
} else if let systemImage {
Image(systemName: systemImage)
- .font(.system(size: 15, weight: .regular))
+ .font(.system(size: 15, weight: .medium))
}
}
- .frame(width: 36, height: 32)
- .foregroundStyle(.primary)
- .background(Color(.tertiarySystemBackground), in: RoundedRectangle(cornerRadius: 6))
+ .frame(width: 40, height: 36)
+ .foregroundStyle(.secondary)
+ .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 9))
.contentShape(Rectangle())
.onTapGesture {
onAction(action)
diff --git a/bookstax/Views/Library/BookDetailView.swift b/bookstax/Views/Library/BookDetailView.swift
index be39904..202052a 100644
--- a/bookstax/Views/Library/BookDetailView.swift
+++ b/bookstax/Views/Library/BookDetailView.swift
@@ -2,9 +2,26 @@ import SwiftUI
struct BookDetailView: View {
let book: BookDTO
+ var shelfName: String? = nil
@State private var viewModel = LibraryViewModel()
@State private var showNewPage = false
@State private var showNewChapter = false
+ @Environment(\.accentTheme) private var theme
+ @Environment(\.dismiss) private var dismiss
+
+ private var breadcrumbs: [Crumb] {
+ if let shelf = shelfName {
+ return [
+ Crumb(label: L("library.title"), action: { dismiss() }),
+ Crumb(label: shelf, action: { dismiss() }),
+ Crumb(label: book.name, action: nil)
+ ]
+ }
+ return [
+ Crumb(label: L("library.title"), action: { dismiss() }),
+ Crumb(label: book.name, action: nil)
+ ]
+ }
var body: some View {
Group {
@@ -28,13 +45,21 @@ struct BookDetailView: View {
.listRowSeparator(.hidden)
}
+ Section {
+ BreadcrumbBar(crumbs: breadcrumbs)
+ .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ }
+
// Chapters with their pages
ForEach(viewModel.chapters) { chapter in
Section {
ForEach(pagesInChapter(chapter.id)) { page in
NavigationLink(value: page) {
ContentRowView(
- icon: "doc.text",
+ icon: "doc.text.fill",
+ iconColor: theme.pageColor,
name: page.name,
description: "",
updatedAt: page.updatedAt
@@ -51,18 +76,27 @@ struct BookDetailView: View {
}
}
} header: {
- Label(chapter.name, systemImage: "list.bullet.rectangle")
+ HStack(spacing: 6) {
+ Image(systemName: "list.bullet.rectangle.fill")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(theme.bookColor)
+ Text(chapter.name)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.primary)
+ }
+ .textCase(nil)
}
}
// Uncategorised pages
let uncategorised = pagesWithoutChapter
if !uncategorised.isEmpty {
- Section(L("book.pages")) {
+ Section {
ForEach(uncategorised) { page in
NavigationLink(value: page) {
ContentRowView(
- icon: "doc.text",
+ icon: "doc.text.fill",
+ iconColor: theme.pageColor,
name: page.name,
description: "",
updatedAt: page.updatedAt
@@ -78,6 +112,11 @@ struct BookDetailView: View {
}
}
}
+ } header: {
+ Text(L("book.pages"))
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.primary)
+ .textCase(nil)
}
}
}
@@ -110,6 +149,12 @@ struct BookDetailView: View {
NewChapterView(bookId: book.id)
}
.task { await viewModel.loadChaptersAndPages(bookId: book.id) }
+ .onChange(of: showNewPage) { _, isShowing in
+ if !isShowing { Task { await viewModel.loadChaptersAndPages(bookId: book.id) } }
+ }
+ .onChange(of: showNewChapter) { _, isShowing in
+ if !isShowing { Task { await viewModel.loadChaptersAndPages(bookId: book.id) } }
+ }
}
private func pagesInChapter(_ chapterId: Int) -> [PageDTO] {
@@ -202,8 +247,49 @@ struct NewChapterView: View {
}
}
-#Preview {
+#Preview("Book Detail") {
NavigationStack {
- BookDetailView(book: .mock)
+ List {
+ Section {
+ ForEach(PageDTO.mockList.filter { $0.chapterId == 1 }) { page in
+ ContentRowView(
+ icon: "doc.text.fill",
+ iconColor: AccentTheme.ocean.pageColor,
+ name: page.name,
+ description: "",
+ updatedAt: page.updatedAt
+ )
+ }
+ } header: {
+ HStack(spacing: 6) {
+ Image(systemName: "list.bullet.rectangle.fill")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(AccentTheme.ocean.bookColor)
+ Text("Getting Started")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.primary)
+ }
+ .textCase(nil)
+ }
+
+ Section {
+ ForEach(PageDTO.mockList.filter { $0.chapterId == nil }) { page in
+ ContentRowView(
+ icon: "doc.text.fill",
+ iconColor: AccentTheme.ocean.pageColor,
+ name: page.name,
+ description: "",
+ updatedAt: page.updatedAt
+ )
+ }
+ } header: {
+ Text("Pages")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.primary)
+ .textCase(nil)
+ }
+ }
+ .listStyle(.insetGrouped)
+ .navigationTitle("iOS Development Guide")
}
}
diff --git a/bookstax/Views/Library/BooksInShelfView.swift b/bookstax/Views/Library/BooksInShelfView.swift
index 8e60cec..898c02d 100644
--- a/bookstax/Views/Library/BooksInShelfView.swift
+++ b/bookstax/Views/Library/BooksInShelfView.swift
@@ -3,6 +3,8 @@ import SwiftUI
struct BooksInShelfView: View {
let shelf: ShelfDTO
@State private var viewModel = LibraryViewModel()
+ @Environment(\.accentTheme) private var theme
+ @Environment(\.dismiss) private var dismiss
var body: some View {
Group {
@@ -26,10 +28,21 @@ struct BooksInShelfView: View {
.listRowSeparator(.hidden)
}
+ Section {
+ BreadcrumbBar(crumbs: [
+ Crumb(label: L("library.title"), action: { dismiss() }),
+ Crumb(label: shelf.name, action: nil)
+ ])
+ .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ }
+
ForEach(viewModel.books) { book in
- NavigationLink(value: book) {
+ NavigationLink(value: BookInShelf(book: book, shelfName: shelf.name)) {
ContentRowView(
- icon: "book.closed",
+ icon: "book.closed.fill",
+ iconColor: theme.bookColor,
name: book.name,
description: book.description,
updatedAt: book.updatedAt
@@ -56,8 +69,20 @@ struct BooksInShelfView: View {
}
}
-#Preview {
+#Preview("Books in Shelf") {
NavigationStack {
- BooksInShelfView(shelf: .mock)
+ List {
+ ForEach(BookDTO.mockList) { book in
+ ContentRowView(
+ icon: "book.closed.fill",
+ iconColor: AccentTheme.ocean.bookColor,
+ name: book.name,
+ description: book.description,
+ updatedAt: book.updatedAt
+ )
+ }
+ }
+ .listStyle(.insetGrouped)
+ .navigationTitle("Engineering Docs")
}
}
diff --git a/bookstax/Views/Library/LibraryView.swift b/bookstax/Views/Library/LibraryView.swift
index 32d46d5..678e287 100644
--- a/bookstax/Views/Library/LibraryView.swift
+++ b/bookstax/Views/Library/LibraryView.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct LibraryView: View {
@State private var viewModel = LibraryViewModel()
@Environment(ConnectivityMonitor.self) private var connectivity
+ @Environment(\.accentTheme) private var theme
var body: some View {
NavigationStack {
@@ -31,7 +32,8 @@ struct LibraryView: View {
ForEach(viewModel.shelves) { shelf in
NavigationLink(value: shelf) {
ContentRowView(
- icon: "books.vertical",
+ icon: "books.vertical.fill",
+ iconColor: theme.shelfColor,
name: shelf.name,
description: shelf.description,
updatedAt: shelf.updatedAt
@@ -51,7 +53,10 @@ struct LibraryView: View {
BooksInShelfView(shelf: shelf)
}
.navigationDestination(for: BookDTO.self) { book in
- BookDetailView(book: book)
+ BookDetailView(book: book, shelfName: nil)
+ }
+ .navigationDestination(for: BookInShelf.self) { item in
+ BookDetailView(book: item.book, shelfName: item.shelfName)
}
.navigationDestination(for: PageDTO.self) { page in
PageReaderView(page: page)
@@ -66,29 +71,90 @@ struct LibraryView: View {
}
}
+// MARK: - Navigation helper
+
+/// Wraps a BookDTO with its parent shelf name so BookDetailView can show a full breadcrumb.
+struct BookInShelf: Hashable {
+ let book: BookDTO
+ let shelfName: String
+}
+
+// MARK: - Breadcrumb bar
+
+/// Crumb model: a label and an optional tap action (nil = current page, not tappable).
+struct Crumb {
+ let label: String
+ let action: (() -> Void)?
+}
+
+struct BreadcrumbBar: View {
+ let crumbs: [Crumb]
+ @Environment(\.accentTheme) private var theme
+
+ var body: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 0) {
+ ForEach(Array(crumbs.enumerated()), id: \.offset) { index, crumb in
+ if index > 0 {
+ Image(systemName: "chevron.right")
+ .font(.footnote.weight(.semibold))
+ .foregroundStyle(.tertiary)
+ .padding(.horizontal, 6)
+ }
+ let isLast = index == crumbs.count - 1
+ if let action = crumb.action {
+ Button(action: action) {
+ Text(crumb.label)
+ .font(.subheadline.weight(.medium))
+ .foregroundStyle(theme.accentColor)
+ .lineLimit(1)
+ }
+ .buttonStyle(.plain)
+ } else {
+ Text(crumb.label)
+ .font(.subheadline.weight(isLast ? .semibold : .medium))
+ .foregroundStyle(isLast ? .primary : .secondary)
+ .lineLimit(1)
+ }
+ }
+ }
+ .padding(.horizontal, 4)
+ .padding(.vertical, 2)
+ }
+ }
+}
+
// MARK: - Reusable content row
struct ContentRowView: View {
let icon: String
+ let iconColor: Color
let name: String
let description: String
let updatedAt: Date
var body: some View {
HStack(spacing: 14) {
- Image(systemName: icon)
- .font(.title3)
- .foregroundStyle(.blue)
- .frame(width: 32)
- .accessibilityHidden(true)
+ // Coloured icon badge
+ ZStack {
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(iconColor.opacity(0.12))
+ .frame(width: 40, height: 40)
+ Image(systemName: icon)
+ .font(.system(size: 17, weight: .medium))
+ .foregroundStyle(iconColor)
+ }
+ .accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
Text(name)
- .font(.body.weight(.medium))
+ .font(.body.weight(.semibold))
+ .foregroundStyle(.primary)
+ .lineLimit(2)
if !description.isEmpty {
Text(description)
- .font(.footnote)
+ .font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
@@ -97,12 +163,35 @@ struct ContentRowView: View {
.font(.caption)
.foregroundStyle(.tertiary)
}
+
+ Spacer(minLength: 0)
}
- .padding(.vertical, 4)
+ .padding(.vertical, 6)
}
}
-#Preview {
+#Preview("Library Row — Shelf") {
+ NavigationStack {
+ List {
+ ContentRowView(icon: "books.vertical.fill", iconColor: AccentTheme.ocean.shelfColor,
+ name: "Engineering Docs",
+ description: "Internal technical documentation for all engineering teams",
+ updatedAt: Date())
+ ContentRowView(icon: "books.vertical.fill", iconColor: AccentTheme.ocean.shelfColor,
+ name: "Project Alpha",
+ description: "Documentation for Project Alpha",
+ updatedAt: Date())
+ ContentRowView(icon: "books.vertical.fill", iconColor: AccentTheme.ocean.shelfColor,
+ name: "HR Policies",
+ description: "",
+ updatedAt: Date())
+ }
+ .listStyle(.insetGrouped)
+ .navigationTitle("Library")
+ }
+}
+
+#Preview("Library") {
LibraryView()
.environment(ConnectivityMonitor.shared)
}
diff --git a/bookstax/Views/Onboarding/OnboardingView.swift b/bookstax/Views/Onboarding/OnboardingView.swift
index c47dfe5..4d32344 100644
--- a/bookstax/Views/Onboarding/OnboardingView.swift
+++ b/bookstax/Views/Onboarding/OnboardingView.swift
@@ -1,5 +1,7 @@
import SwiftUI
+// MARK: - Root
+
struct OnboardingView: View {
@State private var viewModel = OnboardingViewModel()
@State private var langManager = LanguageManager.shared
@@ -9,29 +11,22 @@ struct OnboardingView: View {
if viewModel.isComplete {
Color.clear
} else {
- TabView(selection: $viewModel.currentStep) {
- LanguageStepView(onNext: viewModel.advance)
- .tag(OnboardingViewModel.Step.language)
-
- WelcomeStepView(onNext: viewModel.advance)
- .tag(OnboardingViewModel.Step.welcome)
-
- ServerURLStepView(viewModel: viewModel)
- .tag(OnboardingViewModel.Step.serverURL)
-
- APITokenStepView(viewModel: viewModel)
- .tag(OnboardingViewModel.Step.apiToken)
-
- VerifyStepView(viewModel: viewModel)
- .tag(OnboardingViewModel.Step.verify)
-
- ReadyStepView(onComplete: viewModel.completeOnboarding)
- .tag(OnboardingViewModel.Step.ready)
+ NavigationStack(path: $viewModel.navPath) {
+ LanguageStepView(viewModel: viewModel)
+ .navigationDestination(for: OnboardingViewModel.Step.self) { step in
+ switch step {
+ case .welcome:
+ WelcomeStepView(viewModel: viewModel)
+ case .connect:
+ ConnectStepView(viewModel: viewModel)
+ case .ready:
+ ReadyStepView(onComplete: viewModel.completeOnboarding)
+ case .language:
+ EmptyView()
+ }
+ }
+ .navigationBarHidden(true)
}
- .tabViewStyle(.page(indexDisplayMode: .always))
- .indexViewStyle(.page(backgroundDisplayMode: .always))
- .animation(.easeInOut, value: viewModel.currentStep)
- .ignoresSafeArea()
}
}
.environment(langManager)
@@ -41,7 +36,7 @@ struct OnboardingView: View {
// MARK: - Step 0: Language
struct LanguageStepView: View {
- let onNext: () -> Void
+ @Bindable var viewModel: OnboardingViewModel
@State private var selected: LanguageManager.Language = LanguageManager.shared.current
var body: some View {
@@ -51,11 +46,11 @@ struct LanguageStepView: View {
VStack(spacing: 16) {
ZStack {
Circle()
- .fill(.blue.opacity(0.12))
+ .fill(Color.accentColor.opacity(0.12))
.frame(width: 120, height: 120)
Image(systemName: "globe")
.font(.system(size: 52))
- .foregroundStyle(.blue)
+ .foregroundStyle(Color.accentColor)
}
VStack(spacing: 8) {
@@ -85,19 +80,19 @@ struct LanguageStepView: View {
Spacer()
if selected == lang {
Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(.blue)
+ .foregroundStyle(Color.accentColor)
}
}
.padding()
.background(
selected == lang
- ? Color.blue.opacity(0.1)
+ ? Color.accentColor.opacity(0.1)
: Color(.secondarySystemBackground),
in: RoundedRectangle(cornerRadius: 12)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
- .stroke(selected == lang ? Color.blue : Color.clear, lineWidth: 1.5)
+ .stroke(selected == lang ? Color.accentColor : Color.clear, lineWidth: 1.5)
)
}
.accessibilityLabel(lang.displayName)
@@ -107,12 +102,14 @@ struct LanguageStepView: View {
Spacer()
- Button(action: onNext) {
+ Button {
+ viewModel.push(.welcome)
+ } label: {
Text(L("onboarding.welcome.cta"))
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
- .background(.blue)
+ .background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
@@ -126,7 +123,7 @@ struct LanguageStepView: View {
// MARK: - Step 1: Welcome
struct WelcomeStepView: View {
- let onNext: () -> Void
+ @Bindable var viewModel: OnboardingViewModel
var body: some View {
VStack(spacing: 32) {
@@ -135,11 +132,11 @@ struct WelcomeStepView: View {
VStack(spacing: 16) {
ZStack {
Circle()
- .fill(.blue.opacity(0.12))
+ .fill(Color.accentColor.opacity(0.12))
.frame(width: 120, height: 120)
Image(systemName: "books.vertical.fill")
.font(.system(size: 52))
- .foregroundStyle(.blue)
+ .foregroundStyle(Color.accentColor)
}
VStack(spacing: 8) {
@@ -156,42 +153,49 @@ struct WelcomeStepView: View {
Spacer()
- VStack(spacing: 12) {
- Button(action: onNext) {
- Text(L("onboarding.welcome.cta"))
- .font(.headline)
- .frame(maxWidth: .infinity)
- .padding()
- .background(.blue)
- .foregroundStyle(.white)
- .clipShape(RoundedRectangle(cornerRadius: 14))
- }
- .accessibilityLabel(L("onboarding.welcome.cta"))
+ Button {
+ viewModel.push(.connect)
+ } label: {
+ Text(L("onboarding.welcome.cta"))
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(Color.accentColor)
+ .foregroundStyle(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
}
+ .accessibilityLabel(L("onboarding.welcome.cta"))
.padding(.horizontal, 32)
.padding(.bottom, 48)
}
.padding()
+ .navigationBarBackButtonHidden(false)
}
}
-// MARK: - Step 2: Server URL
+// MARK: - Step 2: Connect (URL + Token + inline verification)
-struct ServerURLStepView: View {
+struct ConnectStepView: View {
@Bindable var viewModel: OnboardingViewModel
+ @State private var showTokenId = false
+ @State private var showTokenSecret = false
+ @State private var showHelp = false
+ @State private var verifyTask: Task? = nil
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
+
+ // Header
VStack(alignment: .leading, spacing: 8) {
Text(L("onboarding.server.title"))
.font(.largeTitle.bold())
-
Text(L("onboarding.server.subtitle"))
.font(.body)
.foregroundStyle(.secondary)
}
+ // Server URL field
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "globe")
@@ -200,6 +204,11 @@ struct ServerURLStepView: View {
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
+ .onChange(of: viewModel.serverURLInput) {
+ if case .idle = viewModel.verifyPhase { } else {
+ viewModel.resetVerification()
+ }
+ }
}
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
@@ -217,46 +226,6 @@ struct ServerURLStepView: View {
}
}
- Button(action: {
- if viewModel.validateServerURL() {
- viewModel.advance()
- }
- }) {
- Text(L("onboarding.server.next"))
- .font(.headline)
- .frame(maxWidth: .infinity)
- .padding()
- .background(.blue)
- .foregroundStyle(.white)
- .clipShape(RoundedRectangle(cornerRadius: 14))
- }
- .padding(.top, 8)
- }
- .padding(32)
- }
- }
-}
-
-// MARK: - Step 3: API Token
-
-struct APITokenStepView: View {
- @Bindable var viewModel: OnboardingViewModel
- @State private var showTokenId = false
- @State private var showTokenSecret = false
- @State private var showHelp = false
-
- var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 24) {
- VStack(alignment: .leading, spacing: 8) {
- Text(L("onboarding.token.title"))
- .font(.largeTitle.bold())
-
- Text(L("onboarding.token.subtitle"))
- .font(.body)
- .foregroundStyle(.secondary)
- }
-
// Help accordion
DisclosureGroup(L("onboarding.token.help"), isExpanded: $showHelp) {
VStack(alignment: .leading, spacing: 6) {
@@ -284,18 +253,23 @@ struct APITokenStepView: View {
}
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
+ .onChange(of: viewModel.tokenIdInput) {
+ if case .idle = viewModel.verifyPhase { } else {
+ viewModel.resetVerification()
+ }
+ }
if UIPasteboard.general.hasStrings {
- Button(action: {
+ Button {
viewModel.tokenIdInput = UIPasteboard.general.string ?? ""
- }) {
+ } label: {
Image(systemName: "clipboard")
.foregroundStyle(.secondary)
}
.accessibilityLabel(L("onboarding.token.paste"))
}
- Button(action: { showTokenId.toggle() }) {
+ Button { showTokenId.toggle() } label: {
Image(systemName: showTokenId ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
@@ -319,18 +293,23 @@ struct APITokenStepView: View {
}
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
+ .onChange(of: viewModel.tokenSecretInput) {
+ if case .idle = viewModel.verifyPhase { } else {
+ viewModel.resetVerification()
+ }
+ }
if UIPasteboard.general.hasStrings {
- Button(action: {
+ Button {
viewModel.tokenSecretInput = UIPasteboard.general.string ?? ""
- }) {
+ } label: {
Image(systemName: "clipboard")
.foregroundStyle(.secondary)
}
.accessibilityLabel(L("onboarding.token.paste"))
}
- Button(action: { showTokenSecret.toggle() }) {
+ Button { showTokenSecret.toggle() } label: {
Image(systemName: showTokenSecret ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
@@ -339,139 +318,128 @@ struct APITokenStepView: View {
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
}
- Button(action: {
- Task { await viewModel.verifyAndSave() }
- }) {
- Text(L("onboarding.token.verify"))
- .font(.headline)
- .frame(maxWidth: .infinity)
- .padding()
- .background(canVerify ? .blue : Color.secondary)
- .foregroundStyle(.white)
- .clipShape(RoundedRectangle(cornerRadius: 14))
+ // Inline verification result / connect button
+ if case .idle = viewModel.verifyPhase {
+ Button {
+ guard viewModel.validateServerURL() else { return }
+ verifyTask?.cancel()
+ verifyTask = Task { await viewModel.verifyAndSave() }
+ } label: {
+ Text(L("onboarding.token.verify"))
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(canConnect ? Color.accentColor : Color(.systemGray3))
+ .foregroundStyle(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ }
+ .disabled(!canConnect)
+ .padding(.top, 8)
+ } else {
+ InlineVerifyPanel(viewModel: viewModel) {
+ // Reset so the user can edit and try again
+ verifyTask?.cancel()
+ viewModel.resetVerification()
+ }
+ .padding(.top, 8)
}
- .disabled(!canVerify)
- .padding(.top, 8)
}
.padding(32)
}
+ .navigationBarBackButtonHidden(false)
+ .onDisappear {
+ verifyTask?.cancel()
+ }
}
- private var canVerify: Bool {
- !viewModel.tokenIdInput.isEmpty && !viewModel.tokenSecretInput.isEmpty
+ private var canConnect: Bool {
+ !viewModel.serverURLInput.isEmpty &&
+ !viewModel.tokenIdInput.isEmpty &&
+ !viewModel.tokenSecretInput.isEmpty
}
}
-// MARK: - Step 4: Verify
+// MARK: - Inline Verify Panel
-struct VerifyStepView: View {
+struct InlineVerifyPanel: View {
@Bindable var viewModel: OnboardingViewModel
+ let onReset: () -> Void
var body: some View {
- VStack(spacing: 32) {
- Spacer()
+ VStack(spacing: 16) {
+ // Phase rows
+ VStack(alignment: .leading, spacing: 12) {
+ VerifyPhaseRow(
+ label: L("onboarding.verify.phase.server"),
+ state: serverPhaseState
+ )
+ VerifyPhaseRow(
+ label: L("onboarding.verify.phase.token"),
+ state: tokenPhaseState
+ )
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
- VStack(spacing: 24) {
- // Title
- VStack(spacing: 8) {
- Image(systemName: verifyIcon)
- .font(.system(size: 56))
- .foregroundStyle(verifyIconColor)
- .animation(.spring, value: verifyIcon)
-
- Text(verifyTitle)
- .font(.title2.bold())
- .multilineTextAlignment(.center)
- }
-
- // Phase step rows
- VStack(alignment: .leading, spacing: 12) {
- VerifyPhaseRow(
- label: L("onboarding.verify.phase.server"),
- state: serverPhaseState
- )
- VerifyPhaseRow(
- label: L("onboarding.verify.phase.token"),
- state: tokenPhaseState
- )
- }
- .padding()
- .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
-
- // Error message
- if let errorMsg = errorMessage {
- Text(errorMsg)
- .font(.footnote)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .padding(.horizontal)
- }
+ // Status message
+ HStack(spacing: 10) {
+ statusIcon
+ Text(statusText)
+ .font(.subheadline)
+ .foregroundStyle(statusColor)
+ .multilineTextAlignment(.leading)
+ Spacer()
}
- Spacer()
+ // Error detail
+ if let msg = errorMessage {
+ Text(msg)
+ .font(.footnote)
+ .foregroundStyle(.red)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
- // Action buttons when failed
+ // Action button shown only on failure
if case .failed = viewModel.verifyPhase {
- HStack(spacing: 16) {
- Button(L("onboarding.verify.goback")) {
- viewModel.goBack()
+ HStack(spacing: 12) {
+ Button {
+ verifyTask?.cancel()
+ verifyTask = Task { await viewModel.verifyAndSave() }
+ } label: {
+ Text(L("onboarding.verify.retry"))
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(Color.accentColor)
+ .foregroundStyle(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
}
- .buttonStyle(.bordered)
- Button(L("onboarding.verify.retry")) {
- Task { await viewModel.verifyAndSave() }
+ Button {
+ onReset()
+ } label: {
+ Text(L("onboarding.verify.goback"))
+ .font(.subheadline)
+ .padding(.vertical, 12)
+ .padding(.horizontal, 16)
+ .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
}
- .buttonStyle(.borderedProminent)
+ .foregroundStyle(.secondary)
}
- .padding(.bottom, 32)
- }
- }
- .padding()
- // Auto-start verification when arriving at this step
- .task {
- if case .idle = viewModel.verifyPhase {
- await viewModel.verifyAndSave()
}
}
}
- // MARK: - Derived state
+ // Retry task stored locally in the panel
+ @State private var verifyTask: Task? = nil
- private var verifyIcon: String {
- switch viewModel.verifyPhase {
- case .idle, .checkingServer, .serverOK, .checkingToken:
- return "arrow.trianglehead.2.clockwise"
- case .done:
- return "checkmark.circle.fill"
- case .failed:
- return "xmark.circle.fill"
- }
- }
-
- private var verifyIconColor: Color {
- switch viewModel.verifyPhase {
- case .done: return .green
- case .failed: return .red
- default: return .blue
- }
- }
-
- private var verifyTitle: String {
- switch viewModel.verifyPhase {
- case .idle: return L("onboarding.verify.ready")
- case .checkingServer: return L("onboarding.verify.reaching")
- case .serverOK(let n): return String(format: L("onboarding.verify.found"), n)
- case .checkingToken: return L("onboarding.verify.checking")
- case .done(let n, _): return String(format: L("onboarding.verify.connected"), n)
- case .failed(let p, _):
- return p == "server" ? L("onboarding.verify.server.failed") : L("onboarding.verify.token.failed")
- }
- }
+ // MARK: Derived state
private var serverPhaseState: VerifyPhaseRow.RowState {
switch viewModel.verifyPhase {
- case .idle: return .pending
- case .checkingServer: return .loading
+ case .idle: return .pending
+ case .checkingServer: return .loading
case .serverOK, .checkingToken, .done: return .success
case .failed(let p, _):
return p == "server" ? .failure : .success
@@ -488,6 +456,40 @@ struct VerifyStepView: View {
}
}
+ private var statusText: String {
+ switch viewModel.verifyPhase {
+ case .idle: return L("onboarding.verify.ready")
+ case .checkingServer: return L("onboarding.verify.reaching")
+ case .serverOK(let n): return String(format: L("onboarding.verify.found"), n)
+ case .checkingToken: return L("onboarding.verify.checking")
+ case .done(let n, _): return String(format: L("onboarding.verify.connected"), n)
+ case .failed(let p, _):
+ return p == "server" ? L("onboarding.verify.server.failed") : L("onboarding.verify.token.failed")
+ }
+ }
+
+ private var statusColor: Color {
+ switch viewModel.verifyPhase {
+ case .done: return .green
+ case .failed: return .red
+ default: return .primary
+ }
+ }
+
+ @ViewBuilder
+ private var statusIcon: some View {
+ switch viewModel.verifyPhase {
+ case .checkingServer, .checkingToken:
+ ProgressView().controlSize(.small)
+ case .done:
+ Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
+ case .failed:
+ Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
+ default:
+ Image(systemName: "arrow.trianglehead.2.clockwise").foregroundStyle(.secondary)
+ }
+ }
+
private var errorMessage: String? {
guard case .failed(_, let error) = viewModel.verifyPhase else { return nil }
return error.errorDescription
@@ -529,7 +531,7 @@ struct VerifyPhaseRow: View {
}
}
-// MARK: - Step 5: Ready
+// MARK: - Step 3: Ready
struct ReadyStepView: View {
let onComplete: () -> Void
@@ -558,7 +560,6 @@ struct ReadyStepView: View {
}
}
- // Feature preview cards
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
FeatureCard(icon: "books.vertical.fill", title: L("onboarding.ready.feature.library"), description: L("onboarding.ready.feature.library.desc"))
@@ -575,13 +576,14 @@ struct ReadyStepView: View {
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
- .background(.blue)
+ .background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal, 32)
.padding(.bottom, 48)
}
+ .navigationBarBackButtonHidden(true)
.onAppear { animate = true }
}
}
@@ -592,28 +594,18 @@ struct FeatureCard: View {
let description: String
var body: some View {
- VStack(alignment: .leading, spacing: 10) {
+ VStack(alignment: .leading, spacing: 8) {
Image(systemName: icon)
.font(.title2)
- .foregroundStyle(.blue)
-
+ .foregroundStyle(Color.accentColor)
Text(title)
- .font(.headline)
-
+ .font(.subheadline.bold())
Text(description)
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding()
- .frame(width: 180, alignment: .leading)
+ .frame(width: 160)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
}
}
-
-#Preview("Welcome") {
- WelcomeStepView(onNext: {})
-}
-
-#Preview("Onboarding Full") {
- OnboardingView()
-}
diff --git a/bookstax/Views/Reader/PageReaderView.swift b/bookstax/Views/Reader/PageReaderView.swift
index ac52345..43310d6 100644
--- a/bookstax/Views/Reader/PageReaderView.swift
+++ b/bookstax/Views/Reader/PageReaderView.swift
@@ -98,6 +98,10 @@ struct PageReaderView: View {
await loadFullPage()
await loadComments()
}
+ .onChange(of: showEditor) { _, isShowing in
+ // Reload page content after editor is dismissed
+ if !isShowing { Task { await loadFullPage() } }
+ }
.onChange(of: colorScheme) {
loadContent()
}
diff --git a/bookstax/Views/Search/SearchView.swift b/bookstax/Views/Search/SearchView.swift
index 293bc83..9fd3b95 100644
--- a/bookstax/Views/Search/SearchView.swift
+++ b/bookstax/Views/Search/SearchView.swift
@@ -21,7 +21,7 @@ struct SearchView: View {
if viewModel.query.isEmpty {
recentSearchesView
} else if viewModel.isSearching {
- LoadingView(message: "Searching…")
+ LoadingView(message: L("search.loading"))
} else if viewModel.results.isEmpty {
ContentUnavailableView.search(text: viewModel.query)
} else {
@@ -60,9 +60,9 @@ struct SearchView: View {
get: { loadError != nil },
set: { if !$0 { loadError = nil } }
)) {
- Button("OK", role: .cancel) {}
+ Button(L("common.ok"), role: .cancel) {}
} message: {
- Text(loadError?.errorDescription ?? "Unknown error")
+ Text(loadError?.errorDescription ?? L("common.error"))
}
}
}
@@ -139,8 +139,8 @@ struct SearchView: View {
if viewModel.recentSearches.isEmpty {
EmptyStateView(
systemImage: "magnifyingglass",
- title: "Search BookStack",
- message: "Search for pages, books, chapters, and shelves across your entire knowledge base."
+ title: L("search.empty.title"),
+ message: L("search.empty.message")
)
} else {
List {
@@ -179,7 +179,7 @@ struct SearchView: View {
viewModel.onFilterChanged()
} label: {
HStack {
- Text("All")
+ Text(L("search.filter.all"))
if viewModel.selectedTypeFilter == nil {
Image(systemName: "checkmark")
}
@@ -227,7 +227,7 @@ struct SearchResultRow: View {
Spacer()
- Text(result.type.rawValue.capitalized)
+ Text(result.type.displayName)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
@@ -244,7 +244,7 @@ struct SearchResultRow: View {
}
}
.padding(.vertical, 4)
- .accessibilityLabel("\(result.type.rawValue.capitalized): \(result.name)")
+ .accessibilityLabel("\(result.type.displayName): \(result.name)")
.accessibilityHint(result.preview ?? "")
}
}
diff --git a/bookstax/Views/Settings/SettingsView.swift b/bookstax/Views/Settings/SettingsView.swift
index 87748cc..dcd57f6 100644
--- a/bookstax/Views/Settings/SettingsView.swift
+++ b/bookstax/Views/Settings/SettingsView.swift
@@ -6,12 +6,20 @@ struct SettingsView: View {
@AppStorage("syncWiFiOnly") private var syncWiFiOnly = true
@AppStorage("showComments") private var showComments = true
@AppStorage("appTheme") private var appTheme = "system"
+ @AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
+ @AppStorage("loggingEnabled") private var loggingEnabled = false
+
+ private var selectedTheme: AccentTheme {
+ AccentTheme(rawValue: accentThemeRaw) ?? .ocean
+ }
@State private var serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
@State private var showSignOutAlert = false
@State private var isSyncing = false
@State private var lastSynced = UserDefaults.standard.object(forKey: "lastSynced") as? Date
@State private var showSafari: URL? = nil
@State private var selectedLanguage: LanguageManager.Language = LanguageManager.shared.current
+ @State private var showLogViewer = false
+ @State private var shareItems: [Any]? = nil
private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
@@ -50,6 +58,39 @@ struct SettingsView: View {
Text(L("settings.appearance.theme.dark")).tag("dark")
}
.pickerStyle(.segmented)
+
+ // Accent colour swatches
+ VStack(alignment: .leading, spacing: 10) {
+ Text(L("settings.appearance.accent"))
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+
+ LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 8), spacing: 10) {
+ ForEach(AccentTheme.allCases) { theme in
+ Button {
+ accentThemeRaw = theme.rawValue
+ } label: {
+ ZStack {
+ Circle()
+ .fill(theme.shelfColor)
+ .frame(width: 32, height: 32)
+ if selectedTheme == theme {
+ Circle()
+ .strokeBorder(.white, lineWidth: 2)
+ .frame(width: 32, height: 32)
+ Image(systemName: "checkmark")
+ .font(.system(size: 11, weight: .bold))
+ .foregroundStyle(.white)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(theme.displayName)
+ .accessibilityAddTraits(selectedTheme == theme ? .isSelected : [])
+ }
+ }
+ }
+ .padding(.vertical, 4)
}
// Account section
@@ -87,6 +128,35 @@ struct SettingsView: View {
Toggle(L("settings.reader.showcomments"), isOn: $showComments)
}
+ // Logging section
+ Section(L("settings.log")) {
+ Toggle(L("settings.log.enabled"), isOn: $loggingEnabled)
+ .onChange(of: loggingEnabled) { _, newValue in
+ LogManager.shared.isEnabled = newValue
+ }
+
+ if loggingEnabled {
+ Button {
+ showLogViewer = true
+ } label: {
+ Label(L("settings.log.viewer.title"), systemImage: "list.bullet.rectangle")
+ }
+
+ Button {
+ let text = LogManager.shared.exportText()
+ shareItems = [text]
+ } label: {
+ Label(L("settings.log.share"), systemImage: "square.and.arrow.up")
+ }
+
+ Button(role: .destructive) {
+ LogManager.shared.clear()
+ } label: {
+ Label(L("settings.log.clear"), systemImage: "trash")
+ }
+ }
+ }
+
// Sync section
Section(L("settings.sync")) {
Toggle(L("settings.sync.wifionly"), isOn: $syncWiFiOnly)
@@ -134,6 +204,9 @@ struct SettingsView: View {
}
}
.navigationTitle(L("settings.title"))
+ .onAppear {
+ loggingEnabled = LogManager.shared.isEnabled
+ }
.alert(L("settings.signout.alert.title"), isPresented: $showSignOutAlert) {
Button(L("settings.signout.alert.confirm"), role: .destructive) { signOut() }
Button(L("settings.signout.alert.cancel"), role: .cancel) {}
@@ -144,6 +217,17 @@ struct SettingsView: View {
SafariView(url: url)
.ignoresSafeArea()
}
+ .sheet(isPresented: $showLogViewer) {
+ LogViewerView()
+ }
+ .sheet(isPresented: Binding(
+ get: { shareItems != nil },
+ set: { if !$0 { shareItems = nil } }
+ )) {
+ if let items = shareItems {
+ ShareSheet(items: items)
+ }
+ }
}
}
@@ -186,6 +270,65 @@ extension URL: @retroactive Identifiable {
public var id: String { absoluteString }
}
+// MARK: - Log Viewer
+
+struct LogViewerView: View {
+ @Environment(\.dismiss) private var dismiss
+ @State private var logManager = LogManager.shared
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if logManager.entries.isEmpty {
+ ContentUnavailableView(
+ L("settings.log.viewer.title"),
+ systemImage: "list.bullet.rectangle",
+ description: Text("No log entries yet.")
+ )
+ } else {
+ List(logManager.entries.reversed()) { entry in
+ VStack(alignment: .leading, spacing: 2) {
+ Text(entry.formatted)
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(colorFor(entry.level))
+ }
+ .listRowInsets(.init(top: 4, leading: 12, bottom: 4, trailing: 12))
+ }
+ .listStyle(.plain)
+ }
+ }
+ .navigationTitle(L("settings.log.viewer.title"))
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button(L("common.ok")) { dismiss() }
+ }
+ }
+ }
+ }
+
+ private func colorFor(_ level: LogEntry.Level) -> Color {
+ switch level {
+ case .debug: return .secondary
+ case .info: return .primary
+ case .warning: return .orange
+ case .error: return .red
+ }
+ }
+}
+
+// MARK: - Share Sheet
+
+struct ShareSheet: UIViewControllerRepresentable {
+ let items: [Any]
+
+ func makeUIViewController(context: Context) -> UIActivityViewController {
+ UIActivityViewController(activityItems: items, applicationActivities: nil)
+ }
+
+ func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
+}
+
#Preview {
SettingsView()
}
diff --git a/bookstax/bookstaxApp.swift b/bookstax/bookstaxApp.swift
index c4a74ce..e2ec16e 100644
--- a/bookstax/bookstaxApp.swift
+++ b/bookstax/bookstaxApp.swift
@@ -12,15 +12,20 @@ import SwiftData
struct bookstaxApp: App {
@AppStorage("onboardingComplete") private var onboardingComplete = false
@AppStorage("appTheme") private var appTheme = "system"
+ @AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
private var preferredColorScheme: ColorScheme? {
switch appTheme {
case "light": return .light
case "dark": return .dark
- default: return nil // nil = follow system
+ default: return nil
}
}
+ private var accentTheme: AccentTheme {
+ AccentTheme(rawValue: accentThemeRaw) ?? .ocean
+ }
+
let sharedModelContainer: ModelContainer = {
let schema = Schema([CachedShelf.self, CachedBook.self, CachedPage.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
@@ -45,6 +50,8 @@ struct bookstaxApp: App {
OnboardingView()
}
}
+ .environment(\.accentTheme, accentTheme)
+ .tint(accentTheme.accentColor)
.preferredColorScheme(preferredColorScheme)
}
.modelContainer(sharedModelContainer)
diff --git a/bookstax/de.lproj/Localizable.strings b/bookstax/de.lproj/Localizable.strings
index 1367dd7..b7ef212 100644
--- a/bookstax/de.lproj/Localizable.strings
+++ b/bookstax/de.lproj/Localizable.strings
@@ -102,15 +102,24 @@
"editor.discard.message" = "Deine Änderungen gehen verloren.";
"editor.discard.confirm" = "Verwerfen";
"editor.discard.keepediting" = "Weiter bearbeiten";
+"editor.image.uploading" = "Bild wird hochgeladen…";
// MARK: - Search
"search.title" = "Suche";
"search.prompt" = "Bücher, Seiten, Kapitel suchen…";
+"search.loading" = "Wird gesucht…";
+"search.empty.title" = "BookStack durchsuchen";
+"search.empty.message" = "Suche nach Seiten, Büchern, Kapiteln und Regalen in deiner gesamten Wissensdatenbank.";
"search.recent" = "Letzte Suchen";
"search.recent.clear" = "Löschen";
"search.filter" = "Suchergebnisse filtern";
+"search.filter.all" = "Alle";
"search.opening" = "Wird geöffnet…";
"search.error.title" = "Ergebnis konnte nicht geöffnet werden";
+"search.type.page" = "Seiten";
+"search.type.book" = "Bücher";
+"search.type.chapter" = "Kapitel";
+"search.type.shelf" = "Regale";
// MARK: - New Content
"create.title" = "Erstellen";
@@ -174,11 +183,20 @@
"settings.appearance.theme.system" = "System";
"settings.appearance.theme.light" = "Hell";
"settings.appearance.theme.dark" = "Dunkel";
+"settings.appearance.accent" = "Akzentfarbe";
// MARK: - Reader Settings
"settings.reader" = "Leser";
"settings.reader.showcomments" = "Kommentare anzeigen";
+// MARK: - Logging
+"settings.log" = "Protokoll";
+"settings.log.enabled" = "Protokollierung aktivieren";
+"settings.log.share" = "Protokoll teilen";
+"settings.log.clear" = "Protokoll löschen";
+"settings.log.viewer.title" = "App-Protokoll";
+"settings.log.entries" = "%d Einträge";
+
// MARK: - Common
"common.ok" = "OK";
"common.error" = "Unbekannter Fehler";
diff --git a/bookstax/en.lproj/Localizable.strings b/bookstax/en.lproj/Localizable.strings
index 43a4efa..4b64fbc 100644
--- a/bookstax/en.lproj/Localizable.strings
+++ b/bookstax/en.lproj/Localizable.strings
@@ -102,15 +102,24 @@
"editor.discard.message" = "Your changes will be lost.";
"editor.discard.confirm" = "Discard";
"editor.discard.keepediting" = "Keep Editing";
+"editor.image.uploading" = "Uploading image…";
// MARK: - Search
"search.title" = "Search";
"search.prompt" = "Search books, pages, chapters…";
+"search.loading" = "Searching…";
+"search.empty.title" = "Search BookStack";
+"search.empty.message" = "Search for pages, books, chapters, and shelves across your entire knowledge base.";
"search.recent" = "Recent Searches";
"search.recent.clear" = "Clear";
"search.filter" = "Filter search results";
+"search.filter.all" = "All";
"search.opening" = "Opening…";
"search.error.title" = "Could not open result";
+"search.type.page" = "Pages";
+"search.type.book" = "Books";
+"search.type.chapter" = "Chapters";
+"search.type.shelf" = "Shelves";
// MARK: - New Content
"create.title" = "Create";
@@ -174,11 +183,20 @@
"settings.appearance.theme.system" = "System";
"settings.appearance.theme.light" = "Light";
"settings.appearance.theme.dark" = "Dark";
+"settings.appearance.accent" = "Accent Colour";
// MARK: - Reader Settings
"settings.reader" = "Reader";
"settings.reader.showcomments" = "Show Comments";
+// MARK: - Logging
+"settings.log" = "Logging";
+"settings.log.enabled" = "Enable Logging";
+"settings.log.share" = "Share Log";
+"settings.log.clear" = "Clear Log";
+"settings.log.viewer.title" = "App Log";
+"settings.log.entries" = "%d entries";
+
// MARK: - Common
"common.ok" = "OK";
"common.error" = "Unknown error";
diff --git a/bookstax/es.lproj/Localizable.strings b/bookstax/es.lproj/Localizable.strings
index 1a98b4b..004c528 100644
--- a/bookstax/es.lproj/Localizable.strings
+++ b/bookstax/es.lproj/Localizable.strings
@@ -102,15 +102,24 @@
"editor.discard.message" = "Se perderán todos tus cambios.";
"editor.discard.confirm" = "Descartar";
"editor.discard.keepediting" = "Seguir editando";
+"editor.image.uploading" = "Subiendo imagen…";
// MARK: - Search
"search.title" = "Búsqueda";
"search.prompt" = "Buscar libros, páginas, capítulos…";
+"search.loading" = "Buscando…";
+"search.empty.title" = "Buscar en BookStack";
+"search.empty.message" = "Busca páginas, libros, capítulos y estantes en toda tu base de conocimiento.";
"search.recent" = "Búsquedas recientes";
"search.recent.clear" = "Borrar";
"search.filter" = "Filtrar resultados";
+"search.filter.all" = "Todos";
"search.opening" = "Abriendo…";
"search.error.title" = "No se pudo abrir el resultado";
+"search.type.page" = "Páginas";
+"search.type.book" = "Libros";
+"search.type.chapter" = "Capítulos";
+"search.type.shelf" = "Estantes";
// MARK: - New Content
"create.title" = "Crear";
@@ -174,11 +183,20 @@
"settings.appearance.theme.system" = "Sistema";
"settings.appearance.theme.light" = "Claro";
"settings.appearance.theme.dark" = "Oscuro";
+"settings.appearance.accent" = "Color de acento";
// MARK: - Reader Settings
"settings.reader" = "Lector";
"settings.reader.showcomments" = "Mostrar comentarios";
+// MARK: - Logging
+"settings.log" = "Registro";
+"settings.log.enabled" = "Activar registro";
+"settings.log.share" = "Compartir registro";
+"settings.log.clear" = "Borrar registro";
+"settings.log.viewer.title" = "Registro de la app";
+"settings.log.entries" = "%d entradas";
+
// MARK: - Common
"common.ok" = "Aceptar";
"common.error" = "Error desconocido";