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 ?? "![\(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) + } + } } 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";