Update 3
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 917 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 388 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 461 KiB |
@@ -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
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ?? ")"
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user