This commit is contained in:
2026-03-21 09:49:01 +01:00
parent 677b927edf
commit 57d0d1092e
29 changed files with 1298 additions and 404 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 917 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

+49
View File
@@ -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`)
+53
View File
@@ -0,0 +1,53 @@
<?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>CFBundleDisplayName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
+4 -12
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 917 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

@@ -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"
+87
View File
@@ -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 }
}
}
+1 -1
View File
@@ -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):
+23 -5
View File
@@ -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?
}
}
+168 -42
View File
@@ -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<ChapterDTO> = 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<PageDTO> = 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<BookDTO> = 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
@@ -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)
+34 -56
View File
@@ -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
@@ -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 ?? "![\(result.name)](\(result.url))"
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)
}
}
}
+182 -45
View File
@@ -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)
+92 -6
View File
@@ -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")
}
}
+29 -4
View File
@@ -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")
}
}
+100 -11
View File
@@ -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)
}
+205 -213
View File
@@ -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<Void, Never>? = 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<Void, Never>? = 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()
}
@@ -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()
}
+8 -8
View File
@@ -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 ?? "")
}
}
+143
View File
@@ -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()
}
+8 -1
View File
@@ -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)
+18
View File
@@ -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";
+18
View File
@@ -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";
+18
View File
@@ -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";