diff --git a/bookstax.xcodeproj/project.pbxproj b/bookstax.xcodeproj/project.pbxproj
index 927c14a..03aea8a 100644
--- a/bookstax.xcodeproj/project.pbxproj
+++ b/bookstax.xcodeproj/project.pbxproj
@@ -91,6 +91,8 @@
knownRegions = (
en,
Base,
+ de,
+ es,
);
mainGroup = 261299CD2F6C686D00EC1C97;
minimizedProjectReferenceProxies = 1;
diff --git a/bookstax/ContentView.swift b/bookstax/ContentView.swift
index 50c6794..6ab555e 100644
--- a/bookstax/ContentView.swift
+++ b/bookstax/ContentView.swift
@@ -5,20 +5,20 @@
// Created by Sven Hanold on 19.03.26.
//
+// ContentView.swift
+// This file is kept for compatibility but is no longer the root view.
+// The app entry point in bookstaxApp.swift routes to OnboardingView or MainTabView.
+
import SwiftUI
struct ContentView: View {
var body: some View {
- VStack {
- Image(systemName: "globe")
- .imageScale(.large)
- .foregroundStyle(.tint)
- Text("Hello, world!")
- }
- .padding()
+ MainTabView()
+ .environment(ConnectivityMonitor.shared)
}
}
#Preview {
ContentView()
+ .environment(ConnectivityMonitor.shared)
}
diff --git a/bookstax/Extensions/Date+Formatting.swift b/bookstax/Extensions/Date+Formatting.swift
new file mode 100644
index 0000000..4080727
--- /dev/null
+++ b/bookstax/Extensions/Date+Formatting.swift
@@ -0,0 +1,37 @@
+import Foundation
+
+extension DateFormatter {
+ static let bookStackDisplay: DateFormatter = {
+ let f = DateFormatter()
+ f.dateStyle = .medium
+ f.timeStyle = .none
+ return f
+ }()
+
+ static let bookStackDateTime: DateFormatter = {
+ let f = DateFormatter()
+ f.dateStyle = .medium
+ f.timeStyle = .short
+ return f
+ }()
+
+ static let bookStackRelative: RelativeDateTimeFormatter = {
+ let f = RelativeDateTimeFormatter()
+ f.unitsStyle = .abbreviated
+ return f
+ }()
+}
+
+extension Date {
+ var bookStackFormatted: String {
+ DateFormatter.bookStackDisplay.string(from: self)
+ }
+
+ var bookStackFormattedWithTime: String {
+ DateFormatter.bookStackDateTime.string(from: self)
+ }
+
+ var bookStackRelative: String {
+ DateFormatter.bookStackRelative.localizedString(for: self, relativeTo: Date())
+ }
+}
diff --git a/bookstax/MockData.swift b/bookstax/MockData.swift
new file mode 100644
index 0000000..e4ef26c
--- /dev/null
+++ b/bookstax/MockData.swift
@@ -0,0 +1,113 @@
+import Foundation
+
+// MARK: - Mock data for SwiftUI Previews and unit tests
+
+extension ShelfDTO {
+ static let mock = ShelfDTO(
+ id: 1,
+ name: "Engineering Docs",
+ slug: "engineering-docs",
+ description: "Internal technical documentation for all engineering teams",
+ createdAt: Date(),
+ updatedAt: Date(),
+ cover: nil
+ )
+
+ static let mockList: [ShelfDTO] = [
+ mock,
+ ShelfDTO(id: 2, name: "Project Alpha", slug: "project-alpha", description: "Documentation for Project Alpha", createdAt: Date(), updatedAt: Date(), cover: nil),
+ ShelfDTO(id: 3, name: "HR Policies", slug: "hr-policies", description: "Company policies and procedures", createdAt: Date(), updatedAt: Date(), cover: nil)
+ ]
+}
+
+extension BookDTO {
+ static let mock = BookDTO(
+ id: 1,
+ name: "iOS Development Guide",
+ slug: "ios-dev",
+ description: "Best practices for building iOS apps",
+ createdAt: Date(),
+ updatedAt: Date(),
+ cover: nil
+ )
+
+ static let mockList: [BookDTO] = [
+ mock,
+ BookDTO(id: 2, name: "API Reference", slug: "api-reference", description: "Complete API documentation", createdAt: Date(), updatedAt: Date(), cover: nil),
+ BookDTO(id: 3, name: "Onboarding Guide", slug: "onboarding", description: "Getting started for new team members", createdAt: Date(), updatedAt: Date(), cover: nil)
+ ]
+}
+
+extension ChapterDTO {
+ static let mock = ChapterDTO(
+ id: 1,
+ bookId: 1,
+ name: "Getting Started",
+ slug: "getting-started",
+ description: "Introduction and setup",
+ priority: 1,
+ createdAt: Date(),
+ updatedAt: Date()
+ )
+}
+
+extension PageDTO {
+ static let mock = PageDTO(
+ id: 1,
+ bookId: 1,
+ chapterId: 1,
+ name: "Installation",
+ slug: "installation",
+ html: "
Installation
Welcome to the guide. Follow these steps to get started.
- Install Xcode
- Clone the repository
- Run the app
",
+ markdown: "# Installation\n\nWelcome to the guide. Follow these steps to get started.\n\n- Install Xcode\n- Clone the repository\n- Run the app",
+ priority: 1,
+ draftStatus: false,
+ createdAt: Date(),
+ updatedAt: Date()
+ )
+
+ static let mockList: [PageDTO] = [
+ mock,
+ PageDTO(id: 2, bookId: 1, chapterId: 1, name: "Configuration", slug: "configuration", html: "Configuration
Configure your environment.
", markdown: "# Configuration\n\nConfigure your environment.", priority: 2, draftStatus: false, createdAt: Date(), updatedAt: Date()),
+ PageDTO(id: 3, bookId: 1, chapterId: nil, name: "Deployment", slug: "deployment", html: "Deployment
Deploy to production.
", markdown: "# Deployment\n\nDeploy to production.", priority: 3, draftStatus: false, createdAt: Date(), updatedAt: Date())
+ ]
+}
+
+extension SearchResultDTO {
+ static let mock = SearchResultDTO(
+ id: 1,
+ name: "Installation Guide",
+ slug: "installation",
+ type: .page,
+ url: "/books/1/page/installation",
+ preview: "Welcome to the guide. Follow these steps to get started..."
+ )
+
+ static let mockList: [SearchResultDTO] = [
+ mock,
+ SearchResultDTO(id: 2, name: "iOS Development Guide", slug: "ios-dev", type: .book, url: "/books/2", preview: nil),
+ SearchResultDTO(id: 3, name: "Getting Started", slug: "getting-started", type: .chapter, url: "/books/1/chapter/getting-started", preview: nil),
+ SearchResultDTO(id: 4, name: "Engineering Docs", slug: "engineering-docs", type: .shelf, url: "/shelves/engineering-docs", preview: nil)
+ ]
+}
+
+extension CommentDTO {
+ static let mock = CommentDTO(
+ id: 1,
+ text: "Great documentation! Very helpful.",
+ html: "Great documentation! Very helpful.
",
+ pageId: 1,
+ createdBy: UserSummaryDTO(id: 1, name: "Alice Johnson", avatarUrl: nil),
+ createdAt: Date(),
+ updatedAt: Date()
+ )
+}
+
+extension UserDTO {
+ static let mock = UserDTO(
+ id: 1,
+ name: "Alice Johnson",
+ email: "alice@example.com",
+ avatarUrl: nil
+ )
+}
diff --git a/bookstax/Models/APIError.swift b/bookstax/Models/APIError.swift
new file mode 100644
index 0000000..85333cc
--- /dev/null
+++ b/bookstax/Models/APIError.swift
@@ -0,0 +1,51 @@
+import Foundation
+
+enum BookStackError: LocalizedError, Sendable {
+ case invalidURL
+ case notAuthenticated
+ case unauthorized
+ case forbidden
+ case notFound(resource: String)
+ case httpError(statusCode: Int, message: String?)
+ case decodingError(String)
+ case networkUnavailable
+ case keychainError(OSStatus)
+ case sslError
+ case timeout
+ case notReachable(host: String)
+ case notBookStack(host: String)
+ case unknown(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidURL:
+ return "The server URL is invalid. Make sure it starts with https://."
+ case .notAuthenticated:
+ return "Please sign in to continue. Go to Settings to reconnect."
+ 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."
+ case .notFound(let resource):
+ return "\(resource) could not be found. It may have been deleted or moved."
+ case .httpError(let code, let message):
+ return message ?? "Server returned error \(code). Check your server URL and try again."
+ case .decodingError(let detail):
+ return "Unexpected response from server. The BookStack version may not be compatible. (\(detail))"
+ case .networkUnavailable:
+ return "No internet connection. Showing cached content."
+ case .keychainError(let status):
+ return "Credential storage failed (code \(status))."
+ case .sslError:
+ return "SSL certificate error. If your server uses a self-signed certificate, contact your admin to install a trusted certificate."
+ case .timeout:
+ return "Request timed out. Make sure your device can reach the server."
+ case .notReachable(let host):
+ return "Could not reach \(host). Make sure the URL is correct and the server is running."
+ case .notBookStack(let host):
+ return "\(host) does not appear to be a BookStack server. Check the URL and try again."
+ case .unknown(let msg):
+ return msg
+ }
+ }
+}
diff --git a/bookstax/Models/DTOs.swift b/bookstax/Models/DTOs.swift
new file mode 100644
index 0000000..10e8d99
--- /dev/null
+++ b/bookstax/Models/DTOs.swift
@@ -0,0 +1,196 @@
+import Foundation
+
+// All DTO types are explicitly nonisolated to ensure their Decodable conformances
+// are not infected by the module-wide @MainActor default isolation, allowing them
+// to be decoded freely inside non-MainActor contexts (e.g. actor BookStackAPI).
+
+// MARK: - Shared
+
+nonisolated struct CoverDTO: Codable, Sendable, Hashable {
+ let url: String
+}
+
+nonisolated struct PaginatedResponse: Codable, Sendable {
+ let data: [T]
+ let total: Int
+
+ enum CodingKeys: String, CodingKey {
+ case data, total
+ }
+}
+
+// MARK: - Shelf
+
+nonisolated struct ShelfDTO: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let name: String
+ let slug: String
+ let description: String
+ let createdAt: Date
+ let updatedAt: Date
+ let cover: CoverDTO?
+
+ enum CodingKeys: String, CodingKey {
+ case id, name, slug, description, cover
+ case createdAt = "created_at"
+ case updatedAt = "updated_at"
+ }
+}
+
+// MARK: - Book
+
+nonisolated struct BookDTO: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let name: String
+ let slug: String
+ let description: String
+ let createdAt: Date
+ let updatedAt: Date
+ let cover: CoverDTO?
+
+ enum CodingKeys: String, CodingKey {
+ case id, name, slug, description, cover
+ case createdAt = "created_at"
+ case updatedAt = "updated_at"
+ }
+}
+
+// MARK: - Chapter
+
+nonisolated struct ChapterDTO: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let bookId: Int
+ let name: String
+ let slug: String
+ let description: String
+ let priority: Int
+ let createdAt: Date
+ let updatedAt: Date
+
+ enum CodingKeys: String, CodingKey {
+ case id, name, slug, description, priority
+ case bookId = "book_id"
+ case createdAt = "created_at"
+ case updatedAt = "updated_at"
+ }
+}
+
+// MARK: - Page
+
+nonisolated struct PageDTO: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let bookId: Int
+ let chapterId: Int?
+ let name: String
+ let slug: String
+ let html: String?
+ let markdown: String?
+ let priority: Int
+ let draftStatus: Bool
+ let createdAt: Date
+ let updatedAt: Date
+
+ enum CodingKeys: String, CodingKey {
+ case id, name, slug, html, markdown, priority
+ case bookId = "book_id"
+ case chapterId = "chapter_id"
+ case draftStatus = "draft"
+ case createdAt = "created_at"
+ case updatedAt = "updated_at"
+ }
+}
+
+// MARK: - Search
+
+nonisolated struct SearchResultDTO: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let name: String
+ let slug: String
+ let type: ContentType
+ let url: String
+ let preview: String?
+
+ enum ContentType: String, Codable, Sendable, CaseIterable {
+ case page, book, chapter, shelf
+
+ var displayName: String {
+ switch self {
+ case .page: return "Pages"
+ case .book: return "Books"
+ case .chapter: return "Chapters"
+ case .shelf: return "Shelves"
+ }
+ }
+
+ var systemImage: String {
+ switch self {
+ case .page: return "doc.text"
+ case .book: return "book.closed"
+ case .chapter: return "list.bullet.rectangle"
+ case .shelf: return "books.vertical"
+ }
+ }
+ }
+}
+
+nonisolated struct SearchResponseDTO: Codable, Sendable {
+ let data: [SearchResultDTO]
+ let total: Int
+}
+
+// MARK: - Comment
+
+nonisolated struct CommentDTO: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let text: String
+ let html: String
+ let pageId: Int
+ let createdBy: UserSummaryDTO
+ let createdAt: Date
+ let updatedAt: Date
+
+ enum CodingKeys: String, CodingKey {
+ case id, text, html
+ case pageId = "entity_id"
+ case createdBy = "created_by"
+ case createdAt = "created_at"
+ case updatedAt = "updated_at"
+ }
+}
+
+nonisolated struct UserSummaryDTO: Codable, Sendable, Hashable {
+ let id: Int
+ let name: String
+ let avatarUrl: String?
+
+ enum CodingKeys: String, CodingKey {
+ case id, name
+ case avatarUrl = "avatar_url"
+ }
+}
+
+// MARK: - API Info
+
+nonisolated struct APIInfo: Codable, Sendable {
+ let version: String
+ let appName: String?
+
+ enum CodingKeys: String, CodingKey {
+ case version
+ case appName = "app_name"
+ }
+}
+
+// MARK: - User
+
+nonisolated struct UserDTO: Codable, Sendable {
+ let id: Int
+ let name: String
+ let email: String
+ let avatarUrl: String?
+
+ enum CodingKeys: String, CodingKey {
+ case id, name, email
+ case avatarUrl = "avatar_url"
+ }
+}
diff --git a/bookstax/Models/SwiftDataModels.swift b/bookstax/Models/SwiftDataModels.swift
new file mode 100644
index 0000000..c646cd0
--- /dev/null
+++ b/bookstax/Models/SwiftDataModels.swift
@@ -0,0 +1,95 @@
+import Foundation
+import SwiftData
+
+@Model
+final class CachedShelf {
+ @Attribute(.unique) var id: Int
+ var name: String
+ var slug: String
+ var shelfDescription: String
+ var coverURL: String?
+ var lastFetched: Date
+
+ init(id: Int, name: String, slug: String, shelfDescription: String, coverURL: String? = nil) {
+ self.id = id
+ self.name = name
+ self.slug = slug
+ self.shelfDescription = shelfDescription
+ self.coverURL = coverURL
+ self.lastFetched = Date()
+ }
+
+ convenience init(from dto: ShelfDTO) {
+ self.init(
+ id: dto.id,
+ name: dto.name,
+ slug: dto.slug,
+ shelfDescription: dto.description,
+ coverURL: dto.cover?.url
+ )
+ }
+}
+
+@Model
+final class CachedBook {
+ @Attribute(.unique) var id: Int
+ var name: String
+ var slug: String
+ var bookDescription: String
+ var coverURL: String?
+ var lastFetched: Date
+
+ init(id: Int, name: String, slug: String, bookDescription: String, coverURL: String? = nil) {
+ self.id = id
+ self.name = name
+ self.slug = slug
+ self.bookDescription = bookDescription
+ self.coverURL = coverURL
+ self.lastFetched = Date()
+ }
+
+ convenience init(from dto: BookDTO) {
+ self.init(
+ id: dto.id,
+ name: dto.name,
+ slug: dto.slug,
+ bookDescription: dto.description,
+ coverURL: dto.cover?.url
+ )
+ }
+}
+
+@Model
+final class CachedPage {
+ @Attribute(.unique) var id: Int
+ var bookId: Int
+ var chapterId: Int?
+ var name: String
+ var slug: String
+ var html: String?
+ var markdown: String?
+ var lastFetched: Date
+
+ init(id: Int, bookId: Int, chapterId: Int? = nil, name: String, slug: String, html: String? = nil, markdown: String? = nil) {
+ self.id = id
+ self.bookId = bookId
+ self.chapterId = chapterId
+ self.name = name
+ self.slug = slug
+ self.html = html
+ self.markdown = markdown
+ self.lastFetched = Date()
+ }
+
+ convenience init(from dto: PageDTO) {
+ self.init(
+ id: dto.id,
+ bookId: dto.bookId,
+ chapterId: dto.chapterId,
+ name: dto.name,
+ slug: dto.slug,
+ html: dto.html,
+ markdown: dto.markdown
+ )
+ }
+}
diff --git a/bookstax/Services/BookStackAPI.swift b/bookstax/Services/BookStackAPI.swift
new file mode 100644
index 0000000..63ba9ba
--- /dev/null
+++ b/bookstax/Services/BookStackAPI.swift
@@ -0,0 +1,412 @@
+import Foundation
+
+actor BookStackAPI {
+ static let shared = BookStackAPI()
+
+ private var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? ""
+ private var tokenId: String = KeychainService.loadSync(key: "tokenId") ?? ""
+ private var tokenSecret: String = KeychainService.loadSync(key: "tokenSecret") ?? ""
+
+ private let decoder: JSONDecoder = {
+ let d = JSONDecoder()
+ // BookStack uses microsecond-precision ISO8601: "2024-01-15T10:30:00.000000Z"
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(abbreviation: "UTC")
+ d.dateDecodingStrategy = .formatted(formatter)
+ return d
+ }()
+
+ // 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")
+ }
+
+ func getServerURL() -> String { serverURL }
+
+ // MARK: - Core Request (no body)
+
+ func request(
+ endpoint: String,
+ method: String = "GET"
+ ) async throws -> T {
+ try await performRequest(endpoint: endpoint, method: method, bodyData: nil)
+ }
+
+ // MARK: - Core Request (with body)
+
+ func request(
+ endpoint: String,
+ method: String,
+ body: Body
+ ) async throws -> T {
+ let bodyData = try JSONEncoder().encode(body)
+ return try await performRequest(endpoint: endpoint, method: method, bodyData: bodyData)
+ }
+
+ // MARK: - Shared implementation
+
+ private func performRequest(
+ endpoint: String,
+ method: String,
+ bodyData: Data?
+ ) async throws -> T {
+ guard !serverURL.isEmpty else {
+ AppLog(.error, "\(method) \(endpoint) — not authenticated (no server URL)", category: "API")
+ throw BookStackError.notAuthenticated
+ }
+ guard let url = URL(string: "\(serverURL)/api/\(endpoint)") else {
+ AppLog(.error, "\(method) \(endpoint) — invalid URL", category: "API")
+ throw BookStackError.invalidURL
+ }
+
+ AppLog(.debug, "\(method) /api/\(endpoint)", category: "API")
+
+ var req = URLRequest(url: url)
+ req.httpMethod = method
+ req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
+ req.setValue("application/json", forHTTPHeaderField: "Accept")
+ req.timeoutInterval = 30
+
+ if let bodyData {
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = bodyData
+ }
+
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await URLSession.shared.data(for: req)
+ } catch let urlError as URLError {
+ let mapped: BookStackError
+ switch urlError.code {
+ case .timedOut:
+ mapped = .timeout
+ case .notConnectedToInternet, .networkConnectionLost:
+ mapped = .networkUnavailable
+ case .serverCertificateUntrusted, .serverCertificateHasBadDate,
+ .serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
+ mapped = .sslError
+ default:
+ mapped = .unknown(urlError.localizedDescription)
+ }
+ AppLog(.error, "\(method) /api/\(endpoint) — network error: \(urlError.localizedDescription)", category: "API")
+ throw mapped
+ }
+
+ guard let http = response as? HTTPURLResponse else {
+ AppLog(.error, "\(method) /api/\(endpoint) — invalid response type", category: "API")
+ throw BookStackError.unknown("Invalid response type")
+ }
+
+ AppLog(.debug, "\(method) /api/\(endpoint) → HTTP \(http.statusCode)", category: "API")
+
+ guard (200..<300).contains(http.statusCode) else {
+ let errorMessage = parseErrorMessage(from: data)
+ let mapped: BookStackError
+ switch http.statusCode {
+ case 401: mapped = .unauthorized
+ case 403: mapped = .forbidden
+ case 404: mapped = .notFound(resource: "Resource")
+ default: mapped = .httpError(statusCode: http.statusCode, message: errorMessage)
+ }
+ AppLog(.error, "\(method) /api/\(endpoint) → HTTP \(http.statusCode)\(errorMessage.map { ": \($0)" } ?? "")", category: "API")
+ throw mapped
+ }
+
+ // Handle 204 No Content
+ if http.statusCode == 204 {
+ guard let empty = EmptyResponse() as? T else {
+ throw BookStackError.decodingError("Expected empty response for 204")
+ }
+ return empty
+ }
+
+ do {
+ let result = try decoder.decode(T.self, from: data)
+ return result
+ } catch {
+ AppLog(.error, "\(method) /api/\(endpoint) — decode error: \(error.localizedDescription)", category: "API")
+ throw BookStackError.decodingError(error.localizedDescription)
+ }
+ }
+
+ private func parseErrorMessage(from data: Data) -> String? {
+ struct APIErrorEnvelope: Codable {
+ struct Inner: Codable { let message: String? }
+ let error: Inner?
+ }
+ return try? JSONDecoder().decode(APIErrorEnvelope.self, from: data).error?.message
+ }
+
+ // MARK: - Shelves
+
+ func fetchShelves(offset: Int = 0, count: Int = 100) async throws -> [ShelfDTO] {
+ let response: PaginatedResponse = try await request(
+ endpoint: "shelves?offset=\(offset)&count=\(count)&sort=+name"
+ )
+ return response.data
+ }
+
+ func fetchShelf(id: Int) async throws -> ShelfBooksResponse {
+ return try await request(endpoint: "shelves/\(id)")
+ }
+
+ func createShelf(name: String, shelfDescription: String) async throws -> ShelfDTO {
+ struct Body: Encodable, Sendable {
+ let name: String
+ let description: String
+ }
+ return try await request(endpoint: "shelves", method: "POST",
+ body: Body(name: name, description: shelfDescription))
+ }
+
+ /// Assign a list of book IDs to a shelf. Replaces the shelf's current book list.
+ func updateShelfBooks(shelfId: Int, bookIds: [Int]) async throws {
+ struct BookRef: Encodable, Sendable { let id: Int }
+ struct Body: Encodable, Sendable { let books: [BookRef] }
+ let _: ShelfDTO = try await request(
+ endpoint: "shelves/\(shelfId)",
+ method: "PUT",
+ body: Body(books: bookIds.map { BookRef(id: $0) })
+ )
+ }
+
+ func deleteShelf(id: Int) async throws {
+ let _: EmptyResponse = try await request(endpoint: "shelves/\(id)", method: "DELETE")
+ }
+
+ // MARK: - Books
+
+ func fetchBooks(offset: Int = 0, count: Int = 100) async throws -> [BookDTO] {
+ let response: PaginatedResponse = try await request(
+ endpoint: "books?offset=\(offset)&count=\(count)&sort=+name"
+ )
+ return response.data
+ }
+
+ func fetchBook(id: Int) async throws -> BookDTO {
+ return try await request(endpoint: "books/\(id)")
+ }
+
+ func createBook(name: String, bookDescription: String) async throws -> BookDTO {
+ struct Body: Encodable, Sendable {
+ let name: String
+ let description: String
+ }
+ return try await request(endpoint: "books", method: "POST",
+ body: Body(name: name, description: bookDescription))
+ }
+
+ func deleteBook(id: Int) async throws {
+ let _: EmptyResponse = try await request(endpoint: "books/\(id)", method: "DELETE")
+ }
+
+ // MARK: - Chapters
+
+ func fetchChapter(id: Int) async throws -> ChapterDTO {
+ return try await request(endpoint: "chapters/\(id)")
+ }
+
+ func fetchChapters(bookId: Int) async throws -> [ChapterDTO] {
+ let response: PaginatedResponse = try await request(
+ endpoint: "chapters?book_id=\(bookId)&count=100&sort=+priority"
+ )
+ return response.data
+ }
+
+ func createChapter(bookId: Int, name: String, chapterDescription: String) async throws -> ChapterDTO {
+ struct Body: Encodable, Sendable {
+ let bookId: Int
+ let name: String
+ let description: String
+ enum CodingKeys: String, CodingKey {
+ case bookId = "book_id"
+ case name, description
+ }
+ }
+ return try await request(endpoint: "chapters", method: "POST",
+ body: Body(bookId: bookId, name: name, description: chapterDescription))
+ }
+
+ func deleteChapter(id: Int) async throws {
+ let _: EmptyResponse = try await request(endpoint: "chapters/\(id)", method: "DELETE")
+ }
+
+ // MARK: - Pages
+
+ func fetchPages(bookId: Int) async throws -> [PageDTO] {
+ let response: PaginatedResponse = try await request(
+ endpoint: "pages?book_id=\(bookId)&count=100&sort=+priority"
+ )
+ return response.data
+ }
+
+ func fetchPage(id: Int) async throws -> PageDTO {
+ return try await request(endpoint: "pages/\(id)")
+ }
+
+ func createPage(bookId: Int, chapterId: Int? = nil, name: String, markdown: String) async throws -> PageDTO {
+ struct Body: Encodable, Sendable {
+ let bookId: Int
+ let chapterId: Int?
+ let name: String
+ let markdown: String
+ enum CodingKeys: String, CodingKey {
+ case bookId = "book_id"
+ case chapterId = "chapter_id"
+ case name, markdown
+ }
+ }
+ return try await request(endpoint: "pages", method: "POST",
+ body: Body(bookId: bookId, chapterId: chapterId, name: name, markdown: markdown))
+ }
+
+ func updatePage(id: Int, name: String, markdown: String) async throws -> PageDTO {
+ struct Body: Encodable, Sendable {
+ let name: String
+ let markdown: String
+ }
+ return try await request(endpoint: "pages/\(id)", method: "PUT",
+ body: Body(name: name, markdown: markdown))
+ }
+
+ func deletePage(id: Int) async throws {
+ let _: EmptyResponse = try await request(endpoint: "pages/\(id)", method: "DELETE")
+ }
+
+ // MARK: - Search
+
+ func search(query: String, type: SearchResultDTO.ContentType? = nil) async throws -> SearchResponseDTO {
+ var queryString = "search?query=\(query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query)"
+ if let type {
+ queryString += "%20[type:\(type.rawValue)]"
+ }
+ return try await request(endpoint: queryString)
+ }
+
+ // MARK: - Comments
+
+ func fetchComments(pageId: Int) async throws -> [CommentDTO] {
+ let response: PaginatedResponse = try await request(
+ endpoint: "comments?entity_type=page&entity_id=\(pageId)"
+ )
+ return response.data
+ }
+
+ func postComment(pageId: Int, text: String) async throws -> CommentDTO {
+ struct Body: Encodable, Sendable {
+ let text: String
+ let entityId: Int
+ let entityType: String
+ enum CodingKeys: String, CodingKey {
+ case text
+ case entityId = "entity_id"
+ case entityType = "entity_type"
+ }
+ }
+ return try await request(endpoint: "comments", method: "POST",
+ body: Body(text: text, entityId: pageId, entityType: "page"))
+ }
+
+ func deleteComment(id: Int) async throws {
+ let _: EmptyResponse = try await request(endpoint: "comments/\(id)", method: "DELETE")
+ }
+
+ // 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")
+ throw BookStackError.invalidURL
+ }
+ var req = URLRequest(url: requestURL)
+ req.httpMethod = "GET"
+ 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 {
+ switch urlError.code {
+ case .timedOut: throw BookStackError.timeout
+ case .notConnectedToInternet, .networkConnectionLost: throw BookStackError.networkUnavailable
+ case .serverCertificateUntrusted, .serverCertificateHasBadDate,
+ .serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
+ throw BookStackError.sslError
+ case .cannotConnectToHost, .cannotFindHost:
+ throw BookStackError.notReachable(host: url)
+ default: throw BookStackError.notReachable(host: url)
+ }
+ }
+
+ guard let http = response as? HTTPURLResponse else {
+ throw BookStackError.notReachable(host: url)
+ }
+
+ // Server is completely unreachable (5xx or no response)
+ guard http.statusCode < 500 else {
+ throw BookStackError.notReachable(host: url)
+ }
+
+ // Best case: /api/info returned valid JSON — decode and return
+ if let info = try? decoder.decode(APIInfo.self, from: data) {
+ AppLog(.info, "Server identified as BookStack \(info.version) (\(info.appName))", category: "Auth")
+ return info
+ }
+
+ // /api/info returned HTML (common when API access is restricted or endpoint is 404'd).
+ // Check if the HTML looks like it came from BookStack by looking for its meta tag.
+ let body = String(data: data, encoding: .utf8) ?? ""
+ if body.contains("meta name=\"base-url\"") || body.contains("BookStack") {
+ AppLog(.info, "Server identified as BookStack via HTML fingerprint", category: "Auth")
+ return APIInfo(version: "unknown", appName: "BookStack")
+ }
+
+ AppLog(.error, "Server at \(url) does not appear to be BookStack", category: "Auth")
+ throw BookStackError.notBookStack(host: url)
+ }
+
+ /// Phase 2: Verify the token works by calling an authenticated endpoint.
+ func verifyToken() async throws {
+ AppLog(.info, "Verifying API token", category: "Auth")
+ // GET /api/books?count=1 requires auth — 401 = bad token, 403 = no API permission
+ let _: PaginatedResponse = try await request(endpoint: "books?count=1")
+ AppLog(.info, "API token verified successfully", category: "Auth")
+ }
+
+
+
+ func verifyConnection() async throws -> APIInfo {
+ return try await request(endpoint: "info")
+ }
+
+ func fetchCurrentUser() async throws -> UserDTO {
+ // Fetch the users list to get current user info
+ let response: PaginatedResponse = try await request(endpoint: "users?count=1")
+ guard let user = response.data.first else {
+ throw BookStackError.notFound(resource: "Current user")
+ }
+ return user
+ }
+}
+
+// MARK: - Helper
+
+nonisolated struct EmptyResponse: Codable, Sendable {
+ init() {}
+}
+
+nonisolated struct ShelfBooksResponse: Codable, Sendable {
+ let id: Int
+ let name: String
+ let books: [BookDTO]
+}
diff --git a/bookstax/Services/ConnectivityMonitor.swift b/bookstax/Services/ConnectivityMonitor.swift
new file mode 100644
index 0000000..5e882ed
--- /dev/null
+++ b/bookstax/Services/ConnectivityMonitor.swift
@@ -0,0 +1,34 @@
+import Network
+import Foundation
+import Observation
+
+@Observable
+@MainActor
+final class ConnectivityMonitor {
+ static let shared = ConnectivityMonitor()
+
+ private(set) var isConnected: Bool = true
+
+ private let monitor = NWPathMonitor()
+ private let queue = DispatchQueue(label: "com.bookstax.connectivity", qos: .utility)
+
+ private init() {
+ monitor.pathUpdateHandler = { [weak self] path in
+ let connected = path.status == .satisfied
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ if self.isConnected != connected {
+ AppLog(connected ? .info : .warning,
+ connected ? "Network connection restored" : "Network connection lost",
+ category: "Network")
+ }
+ self.isConnected = connected
+ }
+ }
+ monitor.start(queue: queue)
+ }
+
+ deinit {
+ monitor.cancel()
+ }
+}
diff --git a/bookstax/Services/KeychainService.swift b/bookstax/Services/KeychainService.swift
new file mode 100644
index 0000000..51f4ff6
--- /dev/null
+++ b/bookstax/Services/KeychainService.swift
@@ -0,0 +1,103 @@
+import Foundation
+import Security
+
+actor KeychainService {
+ static let shared = KeychainService()
+
+ private let service = "com.bookstax.credentials"
+ private let tokenIdKey = "tokenId"
+ private let tokenSecretKey = "tokenSecret"
+
+ // MARK: - Public API
+
+ func saveCredentials(tokenId: String, tokenSecret: String) throws {
+ try save(value: tokenId, key: tokenIdKey)
+ try save(value: tokenSecret, key: tokenSecretKey)
+ }
+
+ func loadCredentials() throws -> (tokenId: String, tokenSecret: String)? {
+ guard let id = try load(key: tokenIdKey),
+ let secret = try load(key: tokenSecretKey) else { return nil }
+ return (id, secret)
+ }
+
+ func deleteCredentials() throws {
+ try delete(key: tokenIdKey)
+ try delete(key: tokenSecretKey)
+ }
+
+ // MARK: - Synchronous static helper (for use at app init before async context)
+
+ static func loadSync(key: String) -> String? {
+ let service = "com.bookstax.credentials"
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: key,
+ kSecReturnData: true,
+ kSecMatchLimit: kSecMatchLimitOne
+ ]
+ var result: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ guard status == errSecSuccess,
+ let data = result as? Data,
+ let string = String(data: data, encoding: .utf8) else { return nil }
+ return string
+ }
+
+ // MARK: - Private helpers
+
+ private func save(value: String, key: String) throws {
+ guard let data = value.data(using: .utf8) else { return }
+ // Delete existing item first to allow update
+ let deleteQuery: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: key
+ ]
+ SecItemDelete(deleteQuery as CFDictionary)
+
+ let addQuery: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: key,
+ kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
+ kSecValueData: data
+ ]
+ let status = SecItemAdd(addQuery as CFDictionary, nil)
+ guard status == errSecSuccess else {
+ throw BookStackError.keychainError(status)
+ }
+ }
+
+ private func load(key: String) throws -> String? {
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: key,
+ kSecReturnData: true,
+ kSecMatchLimit: kSecMatchLimitOne
+ ]
+ var result: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ if status == errSecItemNotFound { return nil }
+ guard status == errSecSuccess,
+ let data = result as? Data,
+ let string = String(data: data, encoding: .utf8) else {
+ throw BookStackError.keychainError(status)
+ }
+ return string
+ }
+
+ private func delete(key: String) throws {
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: key
+ ]
+ let status = SecItemDelete(query as CFDictionary)
+ guard status == errSecSuccess || status == errSecItemNotFound else {
+ throw BookStackError.keychainError(status)
+ }
+ }
+}
diff --git a/bookstax/Services/LanguageManager.swift b/bookstax/Services/LanguageManager.swift
new file mode 100644
index 0000000..9171d6e
--- /dev/null
+++ b/bookstax/Services/LanguageManager.swift
@@ -0,0 +1,59 @@
+import Foundation
+import Observation
+
+/// Manages in-app language selection independently of the system locale.
+@Observable
+final class LanguageManager {
+
+ static let shared = LanguageManager()
+
+ enum Language: String, CaseIterable, Identifiable {
+ case english = "en"
+ case german = "de"
+ case spanish = "es"
+
+ var id: String { rawValue }
+
+ var displayName: String {
+ switch self {
+ case .english: return "English"
+ case .german: return "Deutsch"
+ case .spanish: return "Español"
+ }
+ }
+
+ var flag: String {
+ switch self {
+ case .english: return "🇬🇧"
+ case .german: return "🇩🇪"
+ case .spanish: return "🇪🇸"
+ }
+ }
+ }
+
+ private(set) var current: Language
+
+ private init() {
+ let saved = UserDefaults.standard.string(forKey: "appLanguage") ?? ""
+ current = Language(rawValue: saved) ?? .english
+ }
+
+ func set(_ language: Language) {
+ current = language
+ UserDefaults.standard.set(language.rawValue, forKey: "appLanguage")
+ }
+
+ /// Returns the localised string for key in the currently selected language.
+ func string(_ key: String) -> String {
+ guard let path = Bundle.main.path(forResource: current.rawValue, ofType: "lproj"),
+ let bundle = Bundle(path: path) else {
+ return NSLocalizedString(key, comment: "")
+ }
+ return bundle.localizedString(forKey: key, value: key, table: nil)
+ }
+}
+
+/// Convenience shorthand
+func L(_ key: String) -> String {
+ LanguageManager.shared.string(key)
+}
diff --git a/bookstax/Services/LogManager.swift b/bookstax/Services/LogManager.swift
new file mode 100644
index 0000000..7e9071d
--- /dev/null
+++ b/bookstax/Services/LogManager.swift
@@ -0,0 +1,112 @@
+import Foundation
+import Observation
+
+// MARK: - Log Entry
+
+struct LogEntry: Identifiable, Sendable {
+ enum Level: String, Sendable {
+ case debug = "DEBUG"
+ case info = "INFO"
+ case warning = "WARN"
+ case error = "ERROR"
+
+ var emoji: String {
+ switch self {
+ case .debug: return "🔍"
+ case .info: return "ℹ️"
+ case .warning: return "⚠️"
+ case .error: return "❌"
+ }
+ }
+ }
+
+ let id = UUID()
+ let timestamp: Date
+ let level: Level
+ let category: String
+ let message: String
+
+ var formatted: String {
+ let ts = LogEntry.formatter.string(from: timestamp)
+ return "\(ts) \(level.emoji) [\(category)] \(message)"
+ }
+
+ private static let formatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
+ return f
+ }()
+}
+
+// MARK: - LogManager
+
+@Observable
+@MainActor
+final class LogManager {
+ static let shared = LogManager()
+
+ private(set) var entries: [LogEntry] = []
+ private let maxEntries = 2000
+
+ /// Controlled via Settings toggle. Stored in UserDefaults so it survives restarts.
+ var isEnabled: Bool {
+ get { UserDefaults.standard.bool(forKey: "loggingEnabled") }
+ set { UserDefaults.standard.set(newValue, forKey: "loggingEnabled") }
+ }
+
+ private init() {}
+
+ // MARK: - Logging
+
+ func log(_ level: LogEntry.Level, category: String, _ message: String) {
+ guard isEnabled else { return }
+ let entry = LogEntry(timestamp: Date(), level: level, category: category, message: message)
+ entries.append(entry)
+ if entries.count > maxEntries {
+ entries.removeFirst(entries.count - maxEntries)
+ }
+ }
+
+ func debug(_ message: String, category: String = "App") {
+ log(.debug, category: category, message)
+ }
+
+ func info(_ message: String, category: String = "App") {
+ log(.info, category: category, message)
+ }
+
+ func warning(_ message: String, category: String = "App") {
+ log(.warning, category: category, message)
+ }
+
+ func error(_ message: String, category: String = "App") {
+ log(.error, category: category, message)
+ }
+
+ // MARK: - Export
+
+ func clear() {
+ entries.removeAll()
+ }
+
+ /// Returns the full log as a plain-text string ready for sharing.
+ func exportText() -> String {
+ guard !entries.isEmpty else { return "No log entries." }
+ let header = """
+ BookStax Log Export
+ Generated: \(Date().bookStackFormattedWithTime)
+ Entries: \(entries.count)
+ ────────────────────────────────────────
+
+ """
+ return header + entries.map(\.formatted).joined(separator: "\n")
+ }
+}
+
+// MARK: - Global shorthand (nonisolated — dispatches to MainActor)
+
+func AppLog(_ level: LogEntry.Level = .info, _ message: String, category: String = "App") {
+ Task { @MainActor in
+ LogManager.shared.log(level, category: category, message)
+ }
+}
diff --git a/bookstax/Services/SyncService.swift b/bookstax/Services/SyncService.swift
new file mode 100644
index 0000000..222dc3c
--- /dev/null
+++ b/bookstax/Services/SyncService.swift
@@ -0,0 +1,80 @@
+import Foundation
+import SwiftData
+
+/// SyncService handles upserting API DTOs into the local SwiftData cache.
+/// All methods are @MainActor because ModelContext must be used on the main actor.
+@MainActor
+final class SyncService {
+ static let shared = SyncService()
+
+ private let api = BookStackAPI.shared
+
+ private init() {}
+
+ // MARK: - Sync Shelves
+
+ func syncShelves(context: ModelContext) async throws {
+ let dtos = try await api.fetchShelves()
+ for dto in dtos {
+ let id = dto.id
+ let descriptor = FetchDescriptor(
+ predicate: #Predicate { $0.id == id }
+ )
+ if let existing = try context.fetch(descriptor).first {
+ existing.name = dto.name
+ existing.shelfDescription = dto.description
+ existing.coverURL = dto.cover?.url
+ existing.lastFetched = Date()
+ } else {
+ context.insert(CachedShelf(from: dto))
+ }
+ }
+ try context.save()
+ }
+
+ // MARK: - Sync Books
+
+ func syncBooks(context: ModelContext) async throws {
+ let dtos = try await api.fetchBooks()
+ for dto in dtos {
+ let id = dto.id
+ let descriptor = FetchDescriptor(
+ predicate: #Predicate { $0.id == id }
+ )
+ if let existing = try context.fetch(descriptor).first {
+ existing.name = dto.name
+ existing.bookDescription = dto.description
+ existing.coverURL = dto.cover?.url
+ existing.lastFetched = Date()
+ } else {
+ context.insert(CachedBook(from: dto))
+ }
+ }
+ try context.save()
+ }
+
+ // MARK: - Sync Page (on demand, after viewing)
+
+ func cachePageContent(_ dto: PageDTO, context: ModelContext) throws {
+ let id = dto.id
+ let descriptor = FetchDescriptor(
+ predicate: #Predicate { $0.id == id }
+ )
+ if let existing = try context.fetch(descriptor).first {
+ existing.html = dto.html
+ existing.markdown = dto.markdown
+ existing.lastFetched = Date()
+ } else {
+ context.insert(CachedPage(from: dto))
+ }
+ try context.save()
+ }
+
+ // MARK: - Full sync
+
+ func syncAll(context: ModelContext) async throws {
+ async let shelvesTask: Void = syncShelves(context: context)
+ async let booksTask: Void = syncBooks(context: context)
+ _ = try await (shelvesTask, booksTask)
+ }
+}
diff --git a/bookstax/ViewModels/LibraryViewModel.swift b/bookstax/ViewModels/LibraryViewModel.swift
new file mode 100644
index 0000000..8bb6a15
--- /dev/null
+++ b/bookstax/ViewModels/LibraryViewModel.swift
@@ -0,0 +1,112 @@
+import Foundation
+import Observation
+
+@Observable
+final class LibraryViewModel {
+
+ var shelves: [ShelfDTO] = []
+ var books: [BookDTO] = []
+ var chapters: [ChapterDTO] = []
+ var pages: [PageDTO] = []
+
+ var isLoadingShelves: Bool = false
+ var isLoadingBooks: Bool = false
+ var isLoadingContent: Bool = false
+
+ var error: BookStackError? = nil
+
+ // MARK: - Shelves
+
+ func loadShelves() async {
+ isLoadingShelves = true
+ error = nil
+ AppLog(.info, "Loading shelves", category: "Library")
+ do {
+ shelves = try await BookStackAPI.shared.fetchShelves()
+ AppLog(.info, "Loaded \(shelves.count) shelf(ves)", category: "Library")
+ } catch let e as BookStackError {
+ AppLog(.error, "Failed to load shelves: \(e.localizedDescription)", category: "Library")
+ error = e
+ } catch {
+ AppLog(.error, "Failed to load shelves: \(error.localizedDescription)", category: "Library")
+ self.error = .unknown(error.localizedDescription)
+ }
+ isLoadingShelves = false
+ }
+
+ // MARK: - Books
+
+ func loadBooks() async {
+ isLoadingBooks = true
+ error = nil
+ do {
+ books = try await BookStackAPI.shared.fetchBooks()
+ } catch let e as BookStackError {
+ error = e
+ } catch {
+ self.error = .unknown(error.localizedDescription)
+ }
+ isLoadingBooks = false
+ }
+
+ func loadBooksForShelf(shelfId: Int) async {
+ isLoadingBooks = true
+ error = nil
+ do {
+ let shelfDetail = try await BookStackAPI.shared.fetchShelf(id: shelfId)
+ books = shelfDetail.books
+ } catch let e as BookStackError {
+ error = e
+ } catch {
+ self.error = .unknown(error.localizedDescription)
+ }
+ isLoadingBooks = false
+ }
+
+ // MARK: - Chapters + Pages
+
+ func loadChaptersAndPages(bookId: Int) async {
+ isLoadingContent = true
+ error = nil
+ AppLog(.info, "Loading chapters and pages for book \(bookId)", category: "Library")
+ do {
+ async let chaptersTask = BookStackAPI.shared.fetchChapters(bookId: bookId)
+ async let pagesTask = BookStackAPI.shared.fetchPages(bookId: bookId)
+ let (ch, pg) = try await (chaptersTask, pagesTask)
+ chapters = ch
+ pages = pg
+ AppLog(.info, "Loaded \(ch.count) chapter(s), \(pg.count) page(s) for book \(bookId)", category: "Library")
+ } catch let e as BookStackError {
+ AppLog(.error, "Failed to load book \(bookId) content: \(e.localizedDescription)", category: "Library")
+ error = e
+ } catch {
+ AppLog(.error, "Failed to load book \(bookId) content: \(error.localizedDescription)", category: "Library")
+ self.error = .unknown(error.localizedDescription)
+ }
+ isLoadingContent = false
+ }
+
+ // MARK: - Delete
+
+ func deletePage(id: Int) async {
+ do {
+ try await BookStackAPI.shared.deletePage(id: id)
+ pages.removeAll { $0.id == id }
+ } catch let e as BookStackError {
+ error = e
+ } catch {
+ self.error = .unknown(error.localizedDescription)
+ }
+ }
+
+ func deleteBook(id: Int) async {
+ do {
+ try await BookStackAPI.shared.deleteBook(id: id)
+ books.removeAll { $0.id == id }
+ } catch let e as BookStackError {
+ error = e
+ } catch {
+ self.error = .unknown(error.localizedDescription)
+ }
+ }
+}
diff --git a/bookstax/ViewModels/OnboardingViewModel.swift b/bookstax/ViewModels/OnboardingViewModel.swift
new file mode 100644
index 0000000..c69c370
--- /dev/null
+++ b/bookstax/ViewModels/OnboardingViewModel.swift
@@ -0,0 +1,177 @@
+import Foundation
+import Observation
+
+@Observable
+final class OnboardingViewModel {
+
+ enum Step: Int, CaseIterable {
+ case language = 0
+ case welcome = 1
+ case serverURL = 2
+ case apiToken = 3
+ case verify = 4
+ case ready = 5
+ }
+
+ // Navigation
+ var currentStep: Step = .welcome
+
+ // Input
+ var serverURLInput: String = "https://bs-test.hanold.online"
+ var tokenIdInput: String = ""
+ var tokenSecretInput: String = ""
+
+ // URL validation
+ var serverURLError: String? = nil
+
+ // Verification phases
+ enum VerifyPhase {
+ case idle
+ case checkingServer
+ case serverOK(appName: String)
+ case checkingToken
+ case done(appName: String, userName: String?)
+ case failed(phase: String, error: BookStackError)
+ }
+ 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 goBack() {
+ guard let prev = Step(rawValue: currentStep.rawValue - 1) else { return }
+ currentStep = prev
+ verifyPhase = .idle
+ }
+
+ // MARK: - URL Validation
+
+ func validateServerURL() -> Bool {
+ var url = serverURLInput.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !url.isEmpty else {
+ serverURLError = L("onboarding.server.error.empty")
+ return false
+ }
+ // Auto-prefix https:// if missing
+ if !url.hasPrefix("http://") && !url.hasPrefix("https://") {
+ url = "https://" + url
+ serverURLInput = url
+ }
+ // Strip trailing slash
+ if url.hasSuffix("/") {
+ url = String(url.dropLast())
+ serverURLInput = url
+ }
+ guard URL(string: url) != nil else {
+ serverURLError = L("onboarding.server.error.invalid")
+ return false
+ }
+ serverURLError = nil
+ return true
+ }
+
+ var isHTTP: Bool {
+ serverURLInput.hasPrefix("http://") && !serverURLInput.hasPrefix("https://")
+ }
+
+ // MARK: - Verification
+
+ func verifyAndSave() async {
+ let url = serverURLInput
+ let tokenId = tokenIdInput
+ let tokenSecret = tokenSecretInput
+
+ AppLog(.info, "Starting onboarding verification for \(url)", category: "Onboarding")
+
+ // Phase 1: check the server is reachable and is BookStack
+ verifyPhase = .checkingServer
+ let info: APIInfo
+ do {
+ info = try await BookStackAPI.shared.verifyServerReachable(url: url)
+ } catch let error as BookStackError {
+ AppLog(.error, "Server check failed: \(error.localizedDescription)", category: "Onboarding")
+ verifyPhase = .failed(phase: "server", error: error)
+ return
+ } catch {
+ AppLog(.error, "Server check failed: \(error.localizedDescription)", category: "Onboarding")
+ verifyPhase = .failed(phase: "server", error: .unknown(error.localizedDescription))
+ return
+ }
+
+ let appName = info.appName ?? "BookStack \(info.version)"
+ verifyPhase = .serverOK(appName: appName)
+
+ // Phase 2: configure credentials and verify the token
+ 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)
+ let userName = try? await BookStackAPI.shared.fetchCurrentUser().name
+
+ // Persist server URL and credentials
+ UserDefaults.standard.set(url, forKey: "serverURL")
+ do {
+ try await KeychainService.shared.saveCredentials(tokenId: tokenId, tokenSecret: tokenSecret)
+ } catch let error as BookStackError {
+ AppLog(.error, "Keychain save failed: \(error.localizedDescription)", category: "Onboarding")
+ verifyPhase = .failed(phase: "keychain", error: error)
+ return
+ } catch {
+ AppLog(.error, "Keychain save failed: \(error.localizedDescription)", category: "Onboarding")
+ verifyPhase = .failed(phase: "keychain", error: .unknown(error.localizedDescription))
+ return
+ }
+
+ AppLog(.info, "Onboarding complete — connected to \(appName)\(userName.map { " as \($0)" } ?? "")", category: "Onboarding")
+ verifyPhase = .done(appName: appName, userName: userName)
+ advance() // → .ready
+ }
+
+ // MARK: - Complete
+
+ func completeOnboarding() {
+ UserDefaults.standard.set(true, forKey: "onboardingComplete")
+ AppLog(.info, "Onboarding marked complete", category: "Onboarding")
+ isComplete = true
+ }
+}
diff --git a/bookstax/ViewModels/PageEditorViewModel.swift b/bookstax/ViewModels/PageEditorViewModel.swift
new file mode 100644
index 0000000..8a17c1a
--- /dev/null
+++ b/bookstax/ViewModels/PageEditorViewModel.swift
@@ -0,0 +1,79 @@
+import Foundation
+import Observation
+
+@Observable
+final class PageEditorViewModel {
+
+ enum Mode {
+ case create(bookId: Int, chapterId: Int? = nil)
+ case edit(page: PageDTO)
+ }
+
+ enum EditorTab {
+ case write, preview
+ }
+
+ let mode: Mode
+ var title: String = ""
+ var markdownContent: String = ""
+ var activeTab: EditorTab = .write
+
+ var isSaving: Bool = false
+ var saveError: BookStackError? = nil
+ var savedPage: PageDTO? = nil
+
+ var hasUnsavedChanges: Bool {
+ switch mode {
+ case .create:
+ return !title.isEmpty || !markdownContent.isEmpty
+ case .edit(let page):
+ return title != page.name || markdownContent != (page.markdown ?? "")
+ }
+ }
+
+ init(mode: Mode) {
+ self.mode = mode
+ if case .edit(let page) = mode {
+ title = page.name
+ markdownContent = page.markdown ?? ""
+ }
+ }
+
+ // MARK: - Save
+
+ func save() async {
+ guard !title.isEmpty, !markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
+ isSaving = true
+ saveError = nil
+
+ do {
+ switch mode {
+ case .create(let bookId, let chapterId):
+ AppLog(.info, "Creating page '\(title)' in book \(bookId)", category: "Editor")
+ savedPage = try await BookStackAPI.shared.createPage(
+ bookId: bookId,
+ chapterId: chapterId,
+ name: title,
+ markdown: markdownContent
+ )
+ AppLog(.info, "Page '\(title)' created (id: \(savedPage?.id ?? -1))", category: "Editor")
+ case .edit(let page):
+ AppLog(.info, "Saving edits to page '\(title)' (id: \(page.id))", category: "Editor")
+ savedPage = try await BookStackAPI.shared.updatePage(
+ id: page.id,
+ name: title,
+ markdown: markdownContent
+ )
+ AppLog(.info, "Page '\(title)' saved successfully", category: "Editor")
+ }
+ } catch let e as BookStackError {
+ AppLog(.error, "Save failed for '\(title)': \(e.localizedDescription)", category: "Editor")
+ saveError = e
+ } catch {
+ AppLog(.error, "Save failed for '\(title)': \(error.localizedDescription)", category: "Editor")
+ saveError = .unknown(error.localizedDescription)
+ }
+
+ isSaving = false
+ }
+}
diff --git a/bookstax/ViewModels/SearchViewModel.swift b/bookstax/ViewModels/SearchViewModel.swift
new file mode 100644
index 0000000..35b9efd
--- /dev/null
+++ b/bookstax/ViewModels/SearchViewModel.swift
@@ -0,0 +1,81 @@
+import Foundation
+import Observation
+
+@Observable
+final class SearchViewModel {
+
+ var query: String = ""
+ var results: [SearchResultDTO] = []
+ var isSearching: Bool = false
+ var error: BookStackError? = nil
+ var selectedTypeFilter: SearchResultDTO.ContentType? = nil
+
+ var recentSearches: [String] {
+ get { UserDefaults.standard.stringArray(forKey: "recentSearches") ?? [] }
+ set {
+ var trimmed = newValue.prefix(10).map { $0 }
+ UserDefaults.standard.set(trimmed, forKey: "recentSearches")
+ }
+ }
+
+ private var searchTask: Task?
+
+ // MARK: - Search trigger (call from onChange of query)
+
+ func onQueryChanged() {
+ searchTask?.cancel()
+ guard query.count >= 2 else {
+ results = []
+ return
+ }
+ searchTask = Task {
+ try? await Task.sleep(for: .milliseconds(400))
+ guard !Task.isCancelled else { return }
+ await performSearch()
+ }
+ }
+
+ // MARK: - Filter change
+
+ func onFilterChanged() {
+ guard query.count >= 2 else { return }
+ searchTask?.cancel()
+ searchTask = Task {
+ await performSearch()
+ }
+ }
+
+ // MARK: - Recent searches
+
+ func addToRecent(_ query: String) {
+ var recent = recentSearches.filter { $0 != query }
+ recent.insert(query, at: 0)
+ recentSearches = recent
+ }
+
+ func clearRecentSearches() {
+ recentSearches = []
+ }
+
+ // MARK: - Private
+
+ private func performSearch() async {
+ isSearching = true
+ error = nil
+ let filter = selectedTypeFilter.map { " [type:\($0.rawValue)]" } ?? ""
+ AppLog(.info, "Search: \"\(query)\"\(filter)", category: "Search")
+ do {
+ let response = try await BookStackAPI.shared.search(query: query, type: selectedTypeFilter)
+ results = response.data
+ AppLog(.info, "Search returned \(results.count) result(s) for \"\(query)\"", category: "Search")
+ addToRecent(query)
+ } catch let e as BookStackError {
+ AppLog(.error, "Search failed for \"\(query)\": \(e.localizedDescription)", category: "Search")
+ error = e
+ } catch {
+ AppLog(.error, "Search failed for \"\(query)\": \(error.localizedDescription)", category: "Search")
+ self.error = .unknown(error.localizedDescription)
+ }
+ isSearching = false
+ }
+}
diff --git a/bookstax/Views/Editor/PageEditorView.swift b/bookstax/Views/Editor/PageEditorView.swift
new file mode 100644
index 0000000..55a8759
--- /dev/null
+++ b/bookstax/Views/Editor/PageEditorView.swift
@@ -0,0 +1,469 @@
+import SwiftUI
+import UIKit
+import WebKit
+
+// MARK: - UITextView wrapper that exposes selection-aware formatting
+
+struct MarkdownTextEditor: UIViewRepresentable {
+ @Binding var text: String
+ /// Called with the UITextView so the parent can apply formatting
+ var onTextViewReady: (UITextView) -> Void
+
+ func makeUIView(context: Context) -> UITextView {
+ let tv = UITextView()
+ tv.font = UIFont.monospacedSystemFont(ofSize: 16, weight: .regular)
+ tv.autocorrectionType = .no
+ tv.autocapitalizationType = .none
+ tv.delegate = context.coordinator
+ tv.backgroundColor = .clear
+ tv.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
+ // Set initial text (e.g. when editing an existing page)
+ tv.text = text
+ onTextViewReady(tv)
+ return tv
+ }
+
+ func updateUIView(_ tv: UITextView, context: Context) {
+ // Only push changes that originated outside the UITextView (e.g. formatting toolbar).
+ // Skip updates triggered by the user typing to avoid cursor position resets.
+ guard !context.coordinator.isEditing, tv.text != text else { return }
+ let sel = tv.selectedRange
+ tv.text = text
+ // Clamp selection to new text length
+ let len = tv.text.utf16.count
+ tv.selectedRange = NSRange(location: min(sel.location, len), length: 0)
+ }
+
+ func makeCoordinator() -> Coordinator { Coordinator(self) }
+
+ final class Coordinator: NSObject, UITextViewDelegate {
+ var parent: MarkdownTextEditor
+ /// True while the user is actively editing, so updateUIView won't fight the keyboard.
+ var isEditing = false
+
+ init(_ parent: MarkdownTextEditor) { self.parent = parent }
+
+ func textViewDidBeginEditing(_ textView: UITextView) {
+ isEditing = true
+ }
+
+ func textViewDidEndEditing(_ textView: UITextView) {
+ isEditing = false
+ }
+
+ func textViewDidChange(_ textView: UITextView) {
+ parent.text = textView.text
+ }
+ }
+}
+
+// MARK: - Page Editor
+
+struct PageEditorView: View {
+ @State private var viewModel: PageEditorViewModel
+ @Environment(\.dismiss) private var dismiss
+ @State private var showDiscardAlert = false
+ /// Reference to the underlying UITextView for formatting operations
+ @State private var textView: UITextView? = nil
+
+ init(mode: PageEditorViewModel.Mode) {
+ _viewModel = State(initialValue: PageEditorViewModel(mode: mode))
+ }
+
+ 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)
+
+ 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)
+ }
+ .pickerStyle(.segmented)
+ .padding()
+
+ Divider()
+
+ // Content area
+ if viewModel.activeTab == .write {
+ VStack(spacing: 0) {
+ MarkdownTextEditor(text: $viewModel.markdownContent) { tv in
+ textView = tv
+ }
+
+ Divider()
+ FormattingToolbar { action in
+ applyFormat(action)
+ }
+ }
+ } else {
+ MarkdownPreviewView(markdown: viewModel.markdownContent)
+ }
+
+ // Save error
+ if let error = viewModel.saveError {
+ ErrorBanner(error: error) {
+ Task { await viewModel.save() }
+ }
+ .padding()
+ }
+ }
+ .navigationTitle(navigationTitle)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button(L("editor.cancel")) {
+ if viewModel.hasUnsavedChanges {
+ showDiscardAlert = true
+ } else {
+ dismiss()
+ }
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button(L("editor.save")) {
+ Task {
+ await viewModel.save()
+ if viewModel.saveError == nil {
+ dismiss()
+ }
+ }
+ }
+ .disabled(viewModel.title.isEmpty || viewModel.markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving)
+ .overlay {
+ if viewModel.isSaving {
+ ProgressView().scaleEffect(0.7)
+ }
+ }
+ }
+ }
+ .alert(L("editor.discard.title"), isPresented: $showDiscardAlert) {
+ Button(L("editor.discard.confirm"), role: .destructive) { dismiss() }
+ Button(L("editor.discard.keepediting"), role: .cancel) {}
+ } message: {
+ Text(L("editor.discard.message"))
+ }
+ }
+ }
+
+ private var navigationTitle: String {
+ switch viewModel.mode {
+ case .create: return L("editor.new.title")
+ case .edit: return L("editor.edit.title")
+ }
+ }
+
+ // MARK: - Apply formatting to selected text (or insert at cursor)
+
+ private func applyFormat(_ action: FormatAction) {
+ guard let tv = textView else { return }
+ let text = tv.text ?? ""
+ let nsText = text as NSString
+ let range = tv.selectedRange
+
+ switch action {
+
+ // Inline wrap: surround selection or insert markers
+ case .bold, .italic, .strikethrough, .inlineCode:
+ let marker = action.inlineMarker
+ if range.length > 0 {
+ let selected = nsText.substring(with: range)
+ let replacement = "\(marker)\(selected)\(marker)"
+ replace(in: tv, range: range, with: replacement, cursorOffset: replacement.count)
+ } else {
+ let placeholder = action.placeholder
+ let insertion = "\(marker)\(placeholder)\(marker)"
+ replace(in: tv, range: range, with: insertion,
+ selectRange: NSRange(location: range.location + marker.count,
+ length: placeholder.count))
+ }
+
+ // Line prefix: prepend to each selected line
+ case .h1, .h2, .h3, .bulletList, .numberedList, .blockquote:
+ let prefix = action.linePrefix
+ applyLinePrefix(tv: tv, text: text, nsText: nsText, range: range, prefix: prefix)
+
+ // Block insert at cursor
+ case .codeBlock:
+ let block = "```\n\(range.length > 0 ? nsText.substring(with: range) : "code")\n```"
+ replace(in: tv, range: range, with: block, cursorOffset: block.count)
+
+ case .link:
+ if range.length > 0 {
+ let selected = nsText.substring(with: range)
+ let insertion = "[\(selected)](url)"
+ replace(in: tv, range: range, with: insertion, cursorOffset: insertion.count)
+ } else {
+ let insertion = "[text](url)"
+ replace(in: tv, range: range, with: insertion,
+ selectRange: NSRange(location: range.location + 1, length: 4))
+ }
+
+ case .horizontalRule:
+ // Insert on its own line
+ let before = range.location > 0 ? "\n" : ""
+ let insertion = "\(before)---\n"
+ replace(in: tv, range: range, with: insertion, cursorOffset: insertion.count)
+ }
+ }
+
+ /// Replace a range in the UITextView and sync back to the binding.
+ private func replace(in tv: UITextView, range: NSRange, with string: String,
+ cursorOffset: Int? = nil, selectRange: NSRange? = nil) {
+ guard let swiftRange = Range(range, in: tv.text) else { return }
+ var newText = tv.text!
+ newText.replaceSubrange(swiftRange, with: string)
+ tv.text = newText
+ viewModel.markdownContent = newText
+ // Position cursor
+ if let sel = selectRange {
+ tv.selectedRange = sel
+ } else if let offset = cursorOffset {
+ let newPos = range.location + offset
+ tv.selectedRange = NSRange(location: min(newPos, newText.utf16.count), length: 0)
+ }
+ }
+
+ /// Toggle a line prefix (e.g. `## `) on all lines touched by the selection.
+ private func applyLinePrefix(tv: UITextView, text: String, nsText: NSString,
+ range: NSRange, prefix: String) {
+ // Expand range to full lines
+ let lineRange = nsText.lineRange(for: range)
+ let linesString = nsText.substring(with: lineRange)
+ let lines = linesString.components(separatedBy: "\n")
+
+ // Determine if ALL non-empty lines already have this prefix → toggle off
+ let nonEmpty = lines.filter { !$0.isEmpty }
+ let allPrefixed = !nonEmpty.isEmpty && nonEmpty.allSatisfy { $0.hasPrefix(prefix) }
+
+ let transformed = lines.map { line -> String in
+ if line.isEmpty { return line }
+ if allPrefixed {
+ return String(line.dropFirst(prefix.count))
+ } else {
+ return line.hasPrefix(prefix) ? line : prefix + line
+ }
+ }.joined(separator: "\n")
+
+ replace(in: tv, range: lineRange, with: transformed, cursorOffset: transformed.count)
+ }
+}
+
+// MARK: - Format Actions
+
+enum FormatAction {
+ case h1, h2, h3
+ case bold, italic, strikethrough, inlineCode
+ case bulletList, numberedList, blockquote
+ case link, codeBlock, horizontalRule
+
+ var inlineMarker: String {
+ switch self {
+ case .bold: return "**"
+ case .italic: return "*"
+ case .strikethrough: return "~~"
+ case .inlineCode: return "`"
+ default: return ""
+ }
+ }
+
+ var linePrefix: String {
+ switch self {
+ case .h1: return "# "
+ case .h2: return "## "
+ case .h3: return "### "
+ case .bulletList: return "- "
+ case .numberedList: return "1. "
+ case .blockquote: return "> "
+ default: return ""
+ }
+ }
+
+ var placeholder: String {
+ switch self {
+ case .bold: return "bold"
+ case .italic: return "italic"
+ case .strikethrough: return "text"
+ case .inlineCode: return "code"
+ default: return ""
+ }
+ }
+}
+
+// MARK: - Formatting Toolbar
+
+struct FormattingToolbar: View {
+ 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)
+ }
+ 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)
+ }
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ }
+ .background(Color(.secondarySystemBackground))
+ }
+
+ private var toolbarDivider: some View {
+ Divider()
+ .frame(height: 20)
+ .padding(.horizontal, 4)
+ }
+}
+
+struct FormatButton: View {
+ let label: String?
+ let systemImage: String?
+ let action: FormatAction
+ let onAction: (FormatAction) -> Void
+
+ init(_ label: String, action: FormatAction, onAction: @escaping (FormatAction) -> Void) {
+ self.label = label
+ self.systemImage = nil
+ self.action = action
+ self.onAction = onAction
+ }
+
+ init(systemImage: String, action: FormatAction, onAction: @escaping (FormatAction) -> Void) {
+ self.label = nil
+ self.systemImage = systemImage
+ self.action = action
+ self.onAction = onAction
+ }
+
+ var body: some View {
+ // Use onTapGesture instead of Button so the toolbar tap doesn't
+ // resign the UITextView's first responder (which would clear the selection).
+ Group {
+ if let label {
+ Text(label)
+ .font(.system(size: 13, weight: .semibold, design: .rounded))
+ } else if let systemImage {
+ Image(systemName: systemImage)
+ .font(.system(size: 15, weight: .regular))
+ }
+ }
+ .frame(width: 36, height: 32)
+ .foregroundStyle(.primary)
+ .background(Color(.tertiarySystemBackground), in: RoundedRectangle(cornerRadius: 6))
+ .contentShape(Rectangle())
+ .onTapGesture {
+ onAction(action)
+ }
+ }
+}
+
+// MARK: - Markdown Preview
+
+struct MarkdownPreviewView: View {
+ let markdown: String
+ @State private var webPage = WebPage()
+ @Environment(\.colorScheme) private var colorScheme
+
+ var body: some View {
+ WebView(webPage)
+ .onAppear { loadPreview() }
+ .onChange(of: markdown) { loadPreview() }
+ .onChange(of: colorScheme) { loadPreview() }
+ }
+
+ private func loadPreview() {
+ let html = markdownToHTML(markdown)
+ let isDark = colorScheme == .dark
+ let bg = isDark ? "#1c1c1e" : "#ffffff"
+ let fg = isDark ? "#f2f2f7" : "#000000"
+ let codeBg = isDark ? "#2c2c2e" : "#f2f2f7"
+
+ let fullHTML = """
+
+
+
+
+
+ \(html)
+ """
+ webPage.load(html: fullHTML, baseURL: URL(string: "https://bookstack.example.com")!)
+ }
+
+ /// Minimal Markdown → HTML converter for preview purposes.
+ /// For editing purposes the full rendering happens server-side.
+ private func markdownToHTML(_ md: String) -> String {
+ var html = md
+ // Code blocks (must come before inline code)
+ let codeBlockPattern = #"```[\w]*\n([\s\S]*?)```"#
+ html = html.replacingOccurrences(of: codeBlockPattern, with: "$1
",
+ options: .regularExpression)
+ // Inline code
+ html = html.replacingOccurrences(of: "`([^`]+)`", with: "$1",
+ options: .regularExpression)
+ // Bold + italic
+ html = html.replacingOccurrences(of: "\\*\\*\\*(.+?)\\*\\*\\*", with: "$1",
+ options: .regularExpression)
+ html = html.replacingOccurrences(of: "\\*\\*(.+?)\\*\\*", with: "$1",
+ options: .regularExpression)
+ html = html.replacingOccurrences(of: "\\*(.+?)\\*", with: "$1",
+ options: .regularExpression)
+ // Headings (use (?m) inline flag for multiline anchors)
+ for h in stride(from: 6, through: 1, by: -1) {
+ html = html.replacingOccurrences(of: "(?m)^#{" + "\(h)" + "} (.+)$",
+ with: "$1",
+ options: .regularExpression)
+ }
+ // Horizontal rule
+ html = html.replacingOccurrences(of: "(?m)^---$", with: "
",
+ options: .regularExpression)
+ // Unordered list items
+ html = html.replacingOccurrences(of: "(?m)^[\\-\\*] (.+)$", with: "$1",
+ options: .regularExpression)
+ // Blockquote
+ html = html.replacingOccurrences(of: "(?m)^> (.+)$", with: "$1
",
+ options: .regularExpression)
+ // Paragraphs: double newlines become
+ html = html.replacingOccurrences(of: "\n\n", with: "
")
+ return html
+ }
+}
+
+#Preview("New Page") {
+ PageEditorView(mode: .create(bookId: 1))
+}
+
+#Preview("Edit Page") {
+ PageEditorView(mode: .edit(page: .mock))
+}
diff --git a/bookstax/Views/Library/BookDetailView.swift b/bookstax/Views/Library/BookDetailView.swift
new file mode 100644
index 0000000..be39904
--- /dev/null
+++ b/bookstax/Views/Library/BookDetailView.swift
@@ -0,0 +1,209 @@
+import SwiftUI
+
+struct BookDetailView: View {
+ let book: BookDTO
+ @State private var viewModel = LibraryViewModel()
+ @State private var showNewPage = false
+ @State private var showNewChapter = false
+
+ var body: some View {
+ Group {
+ if viewModel.isLoadingContent && viewModel.chapters.isEmpty && viewModel.pages.isEmpty {
+ LoadingView(message: L("book.loading"))
+ } else if viewModel.chapters.isEmpty && viewModel.pages.isEmpty && viewModel.error == nil {
+ EmptyStateView(
+ systemImage: "doc.text",
+ title: L("book.empty.title"),
+ message: L("book.empty.message"),
+ actionTitle: L("book.addpage"),
+ action: { showNewPage = true }
+ )
+ } else {
+ List {
+ if let error = viewModel.error {
+ ErrorBanner(error: error) {
+ Task { await viewModel.loadChaptersAndPages(bookId: book.id) }
+ }
+ .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",
+ name: page.name,
+ description: "",
+ updatedAt: page.updatedAt
+ )
+ }
+ .accessibilityLabel("Page: \(page.name)")
+ .contextMenu { pageContextMenu(page) }
+ .swipeActions(edge: .trailing) {
+ Button(role: .destructive) {
+ Task { await viewModel.deletePage(id: page.id) }
+ } label: {
+ Label(L("book.delete"), systemImage: "trash")
+ }
+ }
+ }
+ } header: {
+ Label(chapter.name, systemImage: "list.bullet.rectangle")
+ }
+ }
+
+ // Uncategorised pages
+ let uncategorised = pagesWithoutChapter
+ if !uncategorised.isEmpty {
+ Section(L("book.pages")) {
+ ForEach(uncategorised) { page in
+ NavigationLink(value: page) {
+ ContentRowView(
+ icon: "doc.text",
+ name: page.name,
+ description: "",
+ updatedAt: page.updatedAt
+ )
+ }
+ .accessibilityLabel("Page: \(page.name)")
+ .contextMenu { pageContextMenu(page) }
+ .swipeActions(edge: .trailing) {
+ Button(role: .destructive) {
+ Task { await viewModel.deletePage(id: page.id) }
+ } label: {
+ Label(L("book.delete"), systemImage: "trash")
+ }
+ }
+ }
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .refreshable { await viewModel.loadChaptersAndPages(bookId: book.id) }
+ .animation(.easeInOut, value: viewModel.pages.map(\.id))
+ }
+ }
+ .navigationTitle(book.name)
+ .navigationBarTitleDisplayMode(.large)
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Menu {
+ Button { showNewPage = true } label: {
+ Label(L("book.newpage"), systemImage: "doc.text.badge.plus")
+ }
+ Button { showNewChapter = true } label: {
+ Label(L("book.newchapter"), systemImage: "list.bullet.rectangle.portrait.badge.plus")
+ }
+ } label: {
+ Image(systemName: "plus")
+ }
+ .accessibilityLabel("Add content")
+ }
+ }
+ .sheet(isPresented: $showNewPage) {
+ PageEditorView(mode: .create(bookId: book.id))
+ }
+ .sheet(isPresented: $showNewChapter) {
+ NewChapterView(bookId: book.id)
+ }
+ .task { await viewModel.loadChaptersAndPages(bookId: book.id) }
+ }
+
+ private func pagesInChapter(_ chapterId: Int) -> [PageDTO] {
+ viewModel.pages.filter { $0.chapterId == chapterId }
+ .sorted { $0.priority < $1.priority }
+ }
+
+ private var pagesWithoutChapter: [PageDTO] {
+ viewModel.pages.filter { $0.chapterId == nil }
+ .sorted { $0.priority < $1.priority }
+ }
+
+ @ViewBuilder
+ private func pageContextMenu(_ page: PageDTO) -> some View {
+ NavigationLink(value: page) {
+ Label(L("book.open"), systemImage: "arrow.up.right.square")
+ }
+ Button {
+ let url = "\(UserDefaults.standard.string(forKey: "serverURL") ?? "")/books/\(book.slug)/page/\(page.slug)"
+ let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil)
+ if let window = UIApplication.shared.connectedScenes
+ .compactMap({ $0 as? UIWindowScene })
+ .first?.keyWindow {
+ window.rootViewController?.present(activity, animated: true)
+ }
+ } label: {
+ Label(L("book.sharelink"), systemImage: "square.and.arrow.up")
+ }
+ Divider()
+ Button(role: .destructive) {
+ Task { await viewModel.deletePage(id: page.id) }
+ } label: {
+ Label(L("book.delete"), systemImage: "trash")
+ }
+ }
+}
+
+// MARK: - New Chapter sheet
+
+struct NewChapterView: View {
+ let bookId: Int
+ @State private var name = ""
+ @State private var chapterDescription = ""
+ @State private var isSaving = false
+ @State private var error: BookStackError?
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section(L("chapter.details")) {
+ TextField(L("chapter.new.name"), text: $name)
+ TextField(L("chapter.new.description"), text: $chapterDescription)
+ }
+ if let error {
+ Section {
+ ErrorBanner(error: error)
+ .listRowBackground(Color.clear)
+ }
+ }
+ }
+ .navigationTitle(L("chapter.new.title"))
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button(L("chapter.cancel")) { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button(L("chapter.create")) {
+ Task { await save() }
+ }
+ .disabled(name.isEmpty || isSaving)
+ }
+ }
+ }
+ }
+
+ private func save() async {
+ isSaving = true
+ error = nil
+ do {
+ _ = try await BookStackAPI.shared.createChapter(bookId: bookId, name: name, chapterDescription: chapterDescription)
+ dismiss()
+ } catch let e as BookStackError {
+ self.error = e
+ } catch {
+ self.error = .unknown(error.localizedDescription)
+ }
+ isSaving = false
+ }
+}
+
+#Preview {
+ NavigationStack {
+ BookDetailView(book: .mock)
+ }
+}
diff --git a/bookstax/Views/Library/BooksInShelfView.swift b/bookstax/Views/Library/BooksInShelfView.swift
new file mode 100644
index 0000000..8e60cec
--- /dev/null
+++ b/bookstax/Views/Library/BooksInShelfView.swift
@@ -0,0 +1,63 @@
+import SwiftUI
+
+struct BooksInShelfView: View {
+ let shelf: ShelfDTO
+ @State private var viewModel = LibraryViewModel()
+
+ var body: some View {
+ Group {
+ if viewModel.isLoadingBooks && viewModel.books.isEmpty {
+ LoadingView(message: L("shelf.loading"))
+ } else if viewModel.books.isEmpty && viewModel.error == nil {
+ EmptyStateView(
+ systemImage: "book.closed",
+ title: L("shelf.empty.title"),
+ message: L("shelf.empty.message"),
+ actionTitle: L("library.refresh"),
+ action: { Task { await viewModel.loadBooksForShelf(shelfId: shelf.id) } }
+ )
+ } else {
+ List {
+ if let error = viewModel.error {
+ ErrorBanner(error: error) {
+ Task { await viewModel.loadBooksForShelf(shelfId: shelf.id) }
+ }
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ }
+
+ ForEach(viewModel.books) { book in
+ NavigationLink(value: book) {
+ ContentRowView(
+ icon: "book.closed",
+ name: book.name,
+ description: book.description,
+ updatedAt: book.updatedAt
+ )
+ }
+ .accessibilityLabel("Book: \(book.name)")
+ .contextMenu {
+ Button(role: .destructive) {
+ Task { await viewModel.deleteBook(id: book.id) }
+ } label: {
+ Label(L("book.delete"), systemImage: "trash")
+ }
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .refreshable { await viewModel.loadBooksForShelf(shelfId: shelf.id) }
+ .animation(.easeInOut, value: viewModel.books.map(\.id))
+ }
+ }
+ .navigationTitle(shelf.name)
+ .navigationBarTitleDisplayMode(.large)
+ .task { await viewModel.loadBooksForShelf(shelfId: shelf.id) }
+ }
+}
+
+#Preview {
+ NavigationStack {
+ BooksInShelfView(shelf: .mock)
+ }
+}
diff --git a/bookstax/Views/Library/LibraryView.swift b/bookstax/Views/Library/LibraryView.swift
new file mode 100644
index 0000000..32d46d5
--- /dev/null
+++ b/bookstax/Views/Library/LibraryView.swift
@@ -0,0 +1,108 @@
+import SwiftUI
+
+struct LibraryView: View {
+ @State private var viewModel = LibraryViewModel()
+ @Environment(ConnectivityMonitor.self) private var connectivity
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if viewModel.isLoadingShelves && viewModel.shelves.isEmpty {
+ LoadingView(message: L("library.loading"))
+ } else if viewModel.shelves.isEmpty && viewModel.error == nil {
+ EmptyStateView(
+ systemImage: "books.vertical",
+ title: L("library.empty.title"),
+ message: L("library.empty.message"),
+ actionTitle: L("library.refresh"),
+ action: { Task { await viewModel.loadShelves() } }
+ )
+ } else {
+ List {
+ if let error = viewModel.error {
+ ErrorBanner(error: error) {
+ Task { await viewModel.loadShelves() }
+ }
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ }
+
+ Section(L("library.shelves")) {
+ ForEach(viewModel.shelves) { shelf in
+ NavigationLink(value: shelf) {
+ ContentRowView(
+ icon: "books.vertical",
+ name: shelf.name,
+ description: shelf.description,
+ updatedAt: shelf.updatedAt
+ )
+ }
+ .accessibilityLabel("Shelf: \(shelf.name)")
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .refreshable { await viewModel.loadShelves() }
+ .animation(.easeInOut, value: viewModel.shelves.map(\.id))
+ }
+ }
+ .navigationTitle(L("library.title"))
+ .navigationDestination(for: ShelfDTO.self) { shelf in
+ BooksInShelfView(shelf: shelf)
+ }
+ .navigationDestination(for: BookDTO.self) { book in
+ BookDetailView(book: book)
+ }
+ .navigationDestination(for: PageDTO.self) { page in
+ PageReaderView(page: page)
+ }
+ .safeAreaInset(edge: .top) {
+ if !connectivity.isConnected {
+ OfflineBanner()
+ }
+ }
+ }
+ .task { await viewModel.loadShelves() }
+ }
+}
+
+// MARK: - Reusable content row
+
+struct ContentRowView: View {
+ let icon: String
+ 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)
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text(name)
+ .font(.body.weight(.medium))
+
+ if !description.isEmpty {
+ Text(description)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ Text(String(format: L("library.updated"), updatedAt.bookStackRelative))
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
+
+#Preview {
+ LibraryView()
+ .environment(ConnectivityMonitor.shared)
+}
diff --git a/bookstax/Views/MainTabView.swift b/bookstax/Views/MainTabView.swift
new file mode 100644
index 0000000..b77dff0
--- /dev/null
+++ b/bookstax/Views/MainTabView.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+
+struct MainTabView: View {
+ @Environment(ConnectivityMonitor.self) private var connectivity
+
+ var body: some View {
+ TabView {
+ Tab(L("tab.library"), systemImage: "books.vertical") {
+ LibraryView()
+ }
+
+ Tab(L("tab.search"), systemImage: "magnifyingglass") {
+ SearchView()
+ }
+
+ Tab(L("tab.create"), systemImage: "square.and.pencil") {
+ NewContentView()
+ }
+
+ Tab(L("tab.settings"), systemImage: "gear") {
+ SettingsView()
+ }
+ }
+ }
+}
+
+#Preview {
+ MainTabView()
+ .environment(ConnectivityMonitor.shared)
+}
diff --git a/bookstax/Views/NewContent/NewContentView.swift b/bookstax/Views/NewContent/NewContentView.swift
new file mode 100644
index 0000000..961cd4d
--- /dev/null
+++ b/bookstax/Views/NewContent/NewContentView.swift
@@ -0,0 +1,350 @@
+import SwiftUI
+
+struct NewContentView: View {
+ @State private var showNewPage = false
+ @State private var showNewBook = false
+ @State private var showNewShelf = false
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section(L("create.section")) {
+ NewContentButton(
+ icon: "square.and.pencil",
+ title: L("create.page.title"),
+ description: L("create.page.desc")
+ ) {
+ showNewPage = true
+ }
+
+ NewContentButton(
+ icon: "book.closed.fill",
+ title: L("create.book.title"),
+ description: L("create.book.desc")
+ ) {
+ showNewBook = true
+ }
+
+ NewContentButton(
+ icon: "books.vertical.fill",
+ title: L("create.shelf.title"),
+ description: L("create.shelf.desc")
+ ) {
+ showNewShelf = true
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .navigationTitle(L("create.title"))
+ .sheet(isPresented: $showNewPage) {
+ BookPickerThenEditorView()
+ }
+ .sheet(isPresented: $showNewBook) {
+ NewBookView()
+ }
+ .sheet(isPresented: $showNewShelf) {
+ NewShelfView()
+ }
+ }
+ }
+}
+
+// MARK: - New Content Button
+
+struct NewContentButton: View {
+ let icon: String
+ let title: String
+ let description: String
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 14) {
+ Image(systemName: icon)
+ .font(.title2)
+ .foregroundStyle(.blue)
+ .frame(width: 40)
+ .accessibilityHidden(true)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ .font(.body.weight(.medium))
+ .foregroundStyle(.primary)
+ Text(description)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Image(systemName: "chevron.right")
+ .font(.footnote)
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.vertical, 6)
+ }
+ .accessibilityLabel(title)
+ .accessibilityHint(description)
+ }
+}
+
+// MARK: - New Page: Shelf → Book → Editor
+
+struct BookPickerThenEditorView: View {
+ @State private var shelves: [ShelfDTO] = []
+ @State private var allBooks: [BookDTO] = []
+ @State private var filteredBooks: [BookDTO] = []
+ @State private var isLoading = true
+ @State private var isLoadingBooks = false
+ @State private var selectedShelf: ShelfDTO? = nil
+ @State private var selectedBook: BookDTO? = nil
+ @State private var openEditor = false
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ if isLoading {
+ Section {
+ HStack {
+ ProgressView()
+ Text(L("create.page.loading"))
+ .foregroundStyle(.secondary)
+ .padding(.leading, 8)
+ }
+ }
+ } else {
+ // Shelf picker (optional filter)
+ Section {
+ Picker(L("create.shelf.title"), selection: $selectedShelf) {
+ Text(L("create.any.shelf")).tag(ShelfDTO?.none)
+ ForEach(shelves) { shelf in
+ Text(shelf.name).tag(ShelfDTO?.some(shelf))
+ }
+ }
+ .onChange(of: selectedShelf) { _, shelf in
+ selectedBook = nil
+ Task { await filterBooks(for: shelf) }
+ }
+ } header: {
+ Text(L("create.page.filter.shelf"))
+ }
+
+ // Book picker (required)
+ Section {
+ if isLoadingBooks {
+ HStack {
+ ProgressView().controlSize(.small)
+ Text(L("create.loading.books"))
+ .foregroundStyle(.secondary)
+ .padding(.leading, 8)
+ }
+ } else if filteredBooks.isEmpty {
+ Text(selectedShelf == nil ? L("create.page.nobooks") : L("create.page.nobooks.shelf"))
+ .foregroundStyle(.secondary)
+ } else {
+ Picker(L("create.page.book.header"), selection: $selectedBook) {
+ Text(L("create.page.book.select")).tag(BookDTO?.none)
+ ForEach(filteredBooks) { book in
+ Text(book.name).tag(BookDTO?.some(book))
+ }
+ }
+ }
+ } header: {
+ Text(L("create.page.book.header"))
+ } footer: {
+ Text(L("create.page.book.footer"))
+ }
+ }
+ }
+ .navigationTitle(L("create.page.title"))
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button(L("create.cancel")) { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button(L("create.page.next")) { openEditor = true }
+ .disabled(selectedBook == nil)
+ }
+ }
+ .task {
+ async let fetchedShelves = (try? await BookStackAPI.shared.fetchShelves()) ?? []
+ async let fetchedBooks = (try? await BookStackAPI.shared.fetchBooks()) ?? []
+ shelves = await fetchedShelves
+ allBooks = await fetchedBooks
+ filteredBooks = allBooks
+ isLoading = false
+ }
+ .navigationDestination(isPresented: $openEditor) {
+ if let book = selectedBook {
+ PageEditorView(mode: .create(bookId: book.id))
+ }
+ }
+ }
+ }
+
+ private func filterBooks(for shelf: ShelfDTO?) async {
+ guard let shelf else {
+ filteredBooks = allBooks
+ return
+ }
+ isLoadingBooks = true
+ let shelfDetail = try? await BookStackAPI.shared.fetchShelf(id: shelf.id)
+ let shelfBookIds = Set(shelfDetail?.books.map(\.id) ?? [])
+ filteredBooks = allBooks.filter { shelfBookIds.contains($0.id) }
+ isLoadingBooks = false
+ }
+}
+
+// MARK: - New Book View
+
+struct NewBookView: View {
+ @State private var name = ""
+ @State private var bookDescription = ""
+ @State private var shelves: [ShelfDTO] = []
+ @State private var selectedShelf: ShelfDTO? = nil
+ @State private var isLoadingShelves = true
+ @State private var isSaving = false
+ @State private var error: BookStackError?
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section(L("create.book.details")) {
+ TextField(L("create.book.name"), text: $name)
+ TextField(L("create.description"), text: $bookDescription)
+ }
+
+ Section {
+ if isLoadingShelves {
+ HStack {
+ ProgressView().controlSize(.small)
+ Text(L("create.book.shelf.loading"))
+ .foregroundStyle(.secondary)
+ .padding(.leading, 8)
+ }
+ } else {
+ Picker(L("create.shelf.title"), selection: $selectedShelf) {
+ Text(L("create.book.shelf.none")).tag(ShelfDTO?.none)
+ ForEach(shelves) { shelf in
+ Text(shelf.name).tag(ShelfDTO?.some(shelf))
+ }
+ }
+ }
+ } header: {
+ Text(L("create.book.shelf.header"))
+ } footer: {
+ Text(L("create.book.shelf.footer"))
+ }
+
+ if let error {
+ Section {
+ ErrorBanner(error: error)
+ .listRowBackground(Color.clear)
+ }
+ }
+ }
+ .navigationTitle(L("create.book.title"))
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button(L("create.cancel")) { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ if isSaving {
+ ProgressView().controlSize(.small)
+ } else {
+ Button(L("create.create")) {
+ Task { await save() }
+ }
+ .disabled(name.isEmpty)
+ }
+ }
+ }
+ .task {
+ shelves = (try? await BookStackAPI.shared.fetchShelves()) ?? []
+ isLoadingShelves = false
+ }
+ }
+ }
+
+ private func save() async {
+ isSaving = true
+ error = nil
+ do {
+ let book = try await BookStackAPI.shared.createBook(name: name, bookDescription: bookDescription)
+ // If a shelf was selected, append this book to it
+ if let shelf = selectedShelf {
+ // Fetch current book IDs on the shelf, then append the new one
+ let current = (try? await BookStackAPI.shared.fetchShelf(id: shelf.id))?.books.map(\.id) ?? []
+ try await BookStackAPI.shared.updateShelfBooks(shelfId: shelf.id, bookIds: current + [book.id])
+ }
+ dismiss()
+ } catch let e as BookStackError {
+ self.error = e
+ } catch {
+ self.error = .unknown(error.localizedDescription)
+ }
+ isSaving = false
+ }
+}
+
+// MARK: - New Shelf View
+
+struct NewShelfView: View {
+ @State private var name = ""
+ @State private var shelfDescription = ""
+ @State private var isSaving = false
+ @State private var error: BookStackError?
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section(L("create.shelf.details")) {
+ TextField(L("create.shelf.name"), text: $name)
+ TextField(L("create.description"), text: $shelfDescription)
+ }
+ if let error {
+ Section {
+ ErrorBanner(error: error)
+ .listRowBackground(Color.clear)
+ }
+ }
+ }
+ .navigationTitle(L("create.shelf.title"))
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button(L("create.cancel")) { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button(L("create.create")) {
+ Task { await save() }
+ }
+ .disabled(name.isEmpty || isSaving)
+ }
+ }
+ }
+ }
+
+ private func save() async {
+ isSaving = true
+ error = nil
+ do {
+ _ = try await BookStackAPI.shared.createShelf(name: name, shelfDescription: shelfDescription)
+ dismiss()
+ } catch let e as BookStackError {
+ self.error = e
+ } catch {
+ self.error = .unknown(error.localizedDescription)
+ }
+ isSaving = false
+ }
+}
+
+#Preview {
+ NewContentView()
+}
diff --git a/bookstax/Views/Onboarding/OnboardingView.swift b/bookstax/Views/Onboarding/OnboardingView.swift
new file mode 100644
index 0000000..c47dfe5
--- /dev/null
+++ b/bookstax/Views/Onboarding/OnboardingView.swift
@@ -0,0 +1,619 @@
+import SwiftUI
+
+struct OnboardingView: View {
+ @State private var viewModel = OnboardingViewModel()
+ @State private var langManager = LanguageManager.shared
+
+ var body: some View {
+ Group {
+ 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)
+ }
+ .tabViewStyle(.page(indexDisplayMode: .always))
+ .indexViewStyle(.page(backgroundDisplayMode: .always))
+ .animation(.easeInOut, value: viewModel.currentStep)
+ .ignoresSafeArea()
+ }
+ }
+ .environment(langManager)
+ }
+}
+
+// MARK: - Step 0: Language
+
+struct LanguageStepView: View {
+ let onNext: () -> Void
+ @State private var selected: LanguageManager.Language = LanguageManager.shared.current
+
+ var body: some View {
+ VStack(spacing: 32) {
+ Spacer()
+
+ VStack(spacing: 16) {
+ ZStack {
+ Circle()
+ .fill(.blue.opacity(0.12))
+ .frame(width: 120, height: 120)
+ Image(systemName: "globe")
+ .font(.system(size: 52))
+ .foregroundStyle(.blue)
+ }
+
+ VStack(spacing: 8) {
+ Text(L("onboarding.language.title"))
+ .font(.largeTitle.bold())
+ .multilineTextAlignment(.center)
+
+ Text(L("onboarding.language.subtitle"))
+ .font(.body)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ }
+
+ VStack(spacing: 12) {
+ ForEach(LanguageManager.Language.allCases) { lang in
+ Button {
+ selected = lang
+ LanguageManager.shared.set(lang)
+ } label: {
+ HStack(spacing: 14) {
+ Text(lang.flag)
+ .font(.title2)
+ Text(lang.displayName)
+ .font(.body.weight(.medium))
+ .foregroundStyle(.primary)
+ Spacer()
+ if selected == lang {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(.blue)
+ }
+ }
+ .padding()
+ .background(
+ selected == lang
+ ? Color.blue.opacity(0.1)
+ : Color(.secondarySystemBackground),
+ in: RoundedRectangle(cornerRadius: 12)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(selected == lang ? Color.blue : Color.clear, lineWidth: 1.5)
+ )
+ }
+ .accessibilityLabel(lang.displayName)
+ }
+ }
+ .padding(.horizontal, 32)
+
+ Spacer()
+
+ Button(action: onNext) {
+ Text(L("onboarding.welcome.cta"))
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(.blue)
+ .foregroundStyle(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ }
+ .padding(.horizontal, 32)
+ .padding(.bottom, 48)
+ }
+ .padding()
+ }
+}
+
+// MARK: - Step 1: Welcome
+
+struct WelcomeStepView: View {
+ let onNext: () -> Void
+
+ var body: some View {
+ VStack(spacing: 32) {
+ Spacer()
+
+ VStack(spacing: 16) {
+ ZStack {
+ Circle()
+ .fill(.blue.opacity(0.12))
+ .frame(width: 120, height: 120)
+ Image(systemName: "books.vertical.fill")
+ .font(.system(size: 52))
+ .foregroundStyle(.blue)
+ }
+
+ VStack(spacing: 8) {
+ Text(L("onboarding.welcome.title"))
+ .font(.largeTitle.bold())
+ .multilineTextAlignment(.center)
+
+ Text(L("onboarding.welcome.subtitle"))
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ }
+
+ 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"))
+ }
+ .padding(.horizontal, 32)
+ .padding(.bottom, 48)
+ }
+ .padding()
+ }
+}
+
+// MARK: - Step 2: Server URL
+
+struct ServerURLStepView: View {
+ @Bindable var viewModel: OnboardingViewModel
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 24) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(L("onboarding.server.title"))
+ .font(.largeTitle.bold())
+
+ Text(L("onboarding.server.subtitle"))
+ .font(.body)
+ .foregroundStyle(.secondary)
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Image(systemName: "globe")
+ .foregroundStyle(.secondary)
+ TextField(L("onboarding.server.placeholder"), text: $viewModel.serverURLInput)
+ .keyboardType(.URL)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ }
+ .padding()
+ .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
+
+ if let error = viewModel.serverURLError {
+ Label(error, systemImage: "exclamationmark.circle.fill")
+ .font(.footnote)
+ .foregroundStyle(.red)
+ }
+
+ if viewModel.isHTTP {
+ Label(L("onboarding.server.warning.http"), systemImage: "exclamationmark.shield.fill")
+ .font(.footnote)
+ .foregroundStyle(.orange)
+ }
+ }
+
+ 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) {
+ Text(L("onboarding.token.help.steps"))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.top, 8)
+ }
+ .padding()
+ .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
+
+ // Token ID field
+ VStack(alignment: .leading, spacing: 6) {
+ Label(L("onboarding.token.id.label"), systemImage: "key.fill")
+ .font(.subheadline.bold())
+
+ HStack {
+ Group {
+ if showTokenId {
+ TextField(L("onboarding.token.id.label"), text: $viewModel.tokenIdInput)
+ } else {
+ SecureField(L("onboarding.token.id.label"), text: $viewModel.tokenIdInput)
+ }
+ }
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+
+ if UIPasteboard.general.hasStrings {
+ Button(action: {
+ viewModel.tokenIdInput = UIPasteboard.general.string ?? ""
+ }) {
+ Image(systemName: "clipboard")
+ .foregroundStyle(.secondary)
+ }
+ .accessibilityLabel(L("onboarding.token.paste"))
+ }
+
+ Button(action: { showTokenId.toggle() }) {
+ Image(systemName: showTokenId ? "eye.slash" : "eye")
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding()
+ .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
+ }
+
+ // Token Secret field
+ VStack(alignment: .leading, spacing: 6) {
+ Label(L("onboarding.token.secret.label"), systemImage: "lock.fill")
+ .font(.subheadline.bold())
+
+ HStack {
+ Group {
+ if showTokenSecret {
+ TextField(L("onboarding.token.secret.label"), text: $viewModel.tokenSecretInput)
+ } else {
+ SecureField(L("onboarding.token.secret.label"), text: $viewModel.tokenSecretInput)
+ }
+ }
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+
+ if UIPasteboard.general.hasStrings {
+ Button(action: {
+ viewModel.tokenSecretInput = UIPasteboard.general.string ?? ""
+ }) {
+ Image(systemName: "clipboard")
+ .foregroundStyle(.secondary)
+ }
+ .accessibilityLabel(L("onboarding.token.paste"))
+ }
+
+ Button(action: { showTokenSecret.toggle() }) {
+ Image(systemName: showTokenSecret ? "eye.slash" : "eye")
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding()
+ .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))
+ }
+ .disabled(!canVerify)
+ .padding(.top, 8)
+ }
+ .padding(32)
+ }
+ }
+
+ private var canVerify: Bool {
+ !viewModel.tokenIdInput.isEmpty && !viewModel.tokenSecretInput.isEmpty
+ }
+}
+
+// MARK: - Step 4: Verify
+
+struct VerifyStepView: View {
+ @Bindable var viewModel: OnboardingViewModel
+
+ var body: some View {
+ VStack(spacing: 32) {
+ Spacer()
+
+ 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)
+ }
+ }
+
+ Spacer()
+
+ // Action buttons when failed
+ if case .failed = viewModel.verifyPhase {
+ HStack(spacing: 16) {
+ Button(L("onboarding.verify.goback")) {
+ viewModel.goBack()
+ }
+ .buttonStyle(.bordered)
+
+ Button(L("onboarding.verify.retry")) {
+ Task { await viewModel.verifyAndSave() }
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ .padding(.bottom, 32)
+ }
+ }
+ .padding()
+ // Auto-start verification when arriving at this step
+ .task {
+ if case .idle = viewModel.verifyPhase {
+ await viewModel.verifyAndSave()
+ }
+ }
+ }
+
+ // MARK: - Derived state
+
+ 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")
+ }
+ }
+
+ private var serverPhaseState: VerifyPhaseRow.RowState {
+ switch viewModel.verifyPhase {
+ case .idle: return .pending
+ case .checkingServer: return .loading
+ case .serverOK, .checkingToken, .done: return .success
+ case .failed(let p, _):
+ return p == "server" ? .failure : .success
+ }
+ }
+
+ private var tokenPhaseState: VerifyPhaseRow.RowState {
+ switch viewModel.verifyPhase {
+ case .idle, .checkingServer, .serverOK: return .pending
+ case .checkingToken: return .loading
+ case .done: return .success
+ case .failed(let p, _):
+ return p == "token" ? .failure : .pending
+ }
+ }
+
+ private var errorMessage: String? {
+ guard case .failed(_, let error) = viewModel.verifyPhase else { return nil }
+ return error.errorDescription
+ }
+}
+
+// MARK: - Phase Row
+
+struct VerifyPhaseRow: View {
+ enum RowState { case pending, loading, success, failure }
+
+ let label: String
+ let state: RowState
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Group {
+ switch state {
+ case .pending:
+ Image(systemName: "circle")
+ .foregroundStyle(.tertiary)
+ case .loading:
+ ProgressView()
+ .controlSize(.small)
+ case .success:
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ case .failure:
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.red)
+ }
+ }
+ .frame(width: 20, height: 20)
+
+ Text(label)
+ .font(.subheadline)
+ .foregroundStyle(state == .pending ? Color.secondary : Color.primary)
+ }
+ }
+}
+
+// MARK: - Step 5: Ready
+
+struct ReadyStepView: View {
+ let onComplete: () -> Void
+ @State private var animate = false
+
+ var body: some View {
+ VStack(spacing: 32) {
+ Spacer()
+
+ VStack(spacing: 16) {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 80))
+ .foregroundStyle(.green)
+ .scaleEffect(animate ? 1.0 : 0.5)
+ .opacity(animate ? 1.0 : 0.0)
+ .animation(.spring(response: 0.5, dampingFraction: 0.6), value: animate)
+
+ VStack(spacing: 8) {
+ Text(L("onboarding.ready.title"))
+ .font(.largeTitle.bold())
+
+ Text(L("onboarding.ready.subtitle"))
+ .font(.body)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ }
+
+ // 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"))
+ FeatureCard(icon: "magnifyingglass", title: L("onboarding.ready.feature.search"), description: L("onboarding.ready.feature.search.desc"))
+ FeatureCard(icon: "pencil", title: L("onboarding.ready.feature.create"), description: L("onboarding.ready.feature.create.desc"))
+ }
+ .padding(.horizontal, 32)
+ }
+
+ Spacer()
+
+ Button(action: onComplete) {
+ Text(L("onboarding.ready.cta"))
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(.blue)
+ .foregroundStyle(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ }
+ .padding(.horizontal, 32)
+ .padding(.bottom, 48)
+ }
+ .onAppear { animate = true }
+ }
+}
+
+struct FeatureCard: View {
+ let icon: String
+ let title: String
+ let description: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Image(systemName: icon)
+ .font(.title2)
+ .foregroundStyle(.blue)
+
+ Text(title)
+ .font(.headline)
+
+ Text(description)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ .padding()
+ .frame(width: 180, alignment: .leading)
+ .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
+ }
+}
+
+#Preview("Welcome") {
+ WelcomeStepView(onNext: {})
+}
+
+#Preview("Onboarding Full") {
+ OnboardingView()
+}
diff --git a/bookstax/Views/Reader/PageReaderView.swift b/bookstax/Views/Reader/PageReaderView.swift
new file mode 100644
index 0000000..ac52345
--- /dev/null
+++ b/bookstax/Views/Reader/PageReaderView.swift
@@ -0,0 +1,302 @@
+import SwiftUI
+import WebKit
+
+struct PageReaderView: View {
+ let page: PageDTO
+ @State private var webPage = WebPage()
+ @State private var fullPage: PageDTO? = nil
+ @State private var isLoadingPage = false
+ @State private var comments: [CommentDTO] = []
+ @State private var isLoadingComments = false
+ @State private var showEditor = false
+ @State private var isFetchingForEdit = false
+ @State private var newComment = ""
+ @State private var isPostingComment = false
+ @AppStorage("showComments") private var showComments = true
+ @Environment(\.colorScheme) private var colorScheme
+
+ /// The resolved page — full version once fetched, summary until then.
+ private var resolvedPage: PageDTO { fullPage ?? page }
+
+ private var serverURL: String {
+ UserDefaults.standard.string(forKey: "serverURL") ?? "https://bookstack.example.com"
+ }
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ // Page header
+ VStack(alignment: .leading, spacing: 4) {
+ Text(resolvedPage.name)
+ .font(.largeTitle.bold())
+ .padding(.horizontal)
+ .padding(.top)
+
+ Text(String(format: L("library.updated"), resolvedPage.updatedAt.bookStackRelative))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal)
+ .padding(.bottom, 8)
+ }
+
+ // Web content
+ WebView(webPage)
+ .frame(minHeight: 400)
+ .frame(maxWidth: .infinity)
+
+ Divider()
+ .padding(.top)
+
+ // Comments section (hidden when user disabled in Settings)
+ if showComments {
+ DisclosureGroup {
+ commentsContent
+ } label: {
+ Label(String(format: L("reader.comments"), comments.count), systemImage: "bubble.left.and.bubble.right")
+ .font(.headline)
+ }
+ .padding()
+ }
+ }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ Task { await openEditor() }
+ } label: {
+ if isFetchingForEdit {
+ ProgressView().scaleEffect(0.7)
+ } else {
+ Image(systemName: "pencil")
+ }
+ }
+ .disabled(isFetchingForEdit)
+ .accessibilityLabel(L("reader.edit"))
+ }
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ let url = "\(serverURL)/books/\(resolvedPage.bookId)/page/\(resolvedPage.slug)"
+ let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil)
+ if let window = UIApplication.shared.connectedScenes
+ .compactMap({ $0 as? UIWindowScene })
+ .first?.keyWindow {
+ window.rootViewController?.present(activity, animated: true)
+ }
+ } label: {
+ Image(systemName: "square.and.arrow.up")
+ }
+ .accessibilityLabel(L("reader.share"))
+ }
+ }
+ .sheet(isPresented: $showEditor) {
+ if let fullPage {
+ PageEditorView(mode: .edit(page: fullPage))
+ }
+ }
+ .task(id: page.id) {
+ await loadFullPage()
+ await loadComments()
+ }
+ .onChange(of: colorScheme) {
+ loadContent()
+ }
+ }
+
+ // MARK: - Comments UI
+
+ @ViewBuilder
+ private var commentsContent: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ if isLoadingComments {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ .padding()
+ } else if comments.isEmpty {
+ Text(L("reader.comments.empty"))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .padding()
+ } else {
+ ForEach(comments) { comment in
+ CommentRow(comment: comment)
+ Divider()
+ }
+ }
+
+ // New comment input
+ HStack(alignment: .bottom, spacing: 8) {
+ TextField(L("reader.comment.placeholder"), text: $newComment, axis: .vertical)
+ .lineLimit(1...4)
+ .padding(10)
+ .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
+
+ Button {
+ Task { await postComment() }
+ } label: {
+ Image(systemName: "paperplane.fill")
+ .foregroundStyle(newComment.isEmpty ? Color.secondary : Color.blue)
+ }
+ .disabled(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isPostingComment)
+ .accessibilityLabel("Post comment")
+ }
+ .padding(.top, 8)
+ }
+ .padding(.top, 8)
+ }
+
+ // MARK: - Helpers
+
+ private func loadFullPage() async {
+ isLoadingPage = true
+ AppLog(.info, "Loading page content for '\(page.name)' (id: \(page.id))", category: "Reader")
+ do {
+ fullPage = try await BookStackAPI.shared.fetchPage(id: page.id)
+ AppLog(.info, "Page content loaded for '\(page.name)'", category: "Reader")
+ } catch {
+ AppLog(.warning, "Failed to load full page '\(page.name)': \(error.localizedDescription) — using summary", category: "Reader")
+ fullPage = page
+ }
+ isLoadingPage = false
+ loadContent()
+ }
+
+ private func openEditor() async {
+ // Full page is already fetched by loadFullPage; if still loading, wait briefly
+ if fullPage == nil {
+ isFetchingForEdit = true
+ fullPage = (try? await BookStackAPI.shared.fetchPage(id: page.id)) ?? page
+ isFetchingForEdit = false
+ }
+ showEditor = true
+ }
+
+ private func loadContent() {
+ let html = buildHTML(content: resolvedPage.html ?? "\(L("reader.nocontent"))
")
+ webPage.load(html: html, baseURL: URL(string: serverURL) ?? URL(string: "https://bookstack.example.com")!)
+ }
+
+ private func loadComments() async {
+ isLoadingComments = true
+ comments = (try? await BookStackAPI.shared.fetchComments(pageId: page.id)) ?? []
+ isLoadingComments = false
+ }
+
+ private func postComment() async {
+ let text = newComment.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty else { return }
+ isPostingComment = true
+ AppLog(.info, "Posting comment on page '\(page.name)' (id: \(page.id))", category: "Reader")
+ do {
+ let comment = try await BookStackAPI.shared.postComment(pageId: page.id, text: text)
+ comments.append(comment)
+ newComment = ""
+ AppLog(.info, "Comment posted on '\(page.name)'", category: "Reader")
+ } catch {
+ AppLog(.error, "Failed to post comment on '\(page.name)': \(error.localizedDescription)", category: "Reader")
+ }
+ isPostingComment = false
+ }
+
+ private func buildHTML(content: String) -> String {
+ let isDark = colorScheme == .dark
+ let bg = isDark ? "#1c1c1e" : "#ffffff"
+ let fg = isDark ? "#f2f2f7" : "#000000"
+ let codeBg = isDark ? "#2c2c2e" : "#f2f2f7"
+ let border = isDark ? "#3a3a3c" : "#d1d1d6"
+
+ return """
+
+
+
+
+
+
+
+ \(content)
+
+ """
+ }
+}
+
+// MARK: - Comment Row
+
+struct CommentRow: View {
+ let comment: CommentDTO
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 10) {
+ Circle()
+ .fill(.blue.gradient)
+ .frame(width: 32, height: 32)
+ .overlay {
+ Text(comment.createdBy.name.prefix(1).uppercased())
+ .font(.footnote.bold())
+ .foregroundStyle(.white)
+ }
+ .accessibilityHidden(true)
+
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(comment.createdBy.name)
+ .font(.footnote.bold())
+ Spacer()
+ Text(comment.createdAt.bookStackRelative)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ Text(comment.text)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
+
+#Preview {
+ NavigationStack {
+ PageReaderView(page: .mock)
+ }
+}
diff --git a/bookstax/Views/Search/SearchView.swift b/bookstax/Views/Search/SearchView.swift
new file mode 100644
index 0000000..293bc83
--- /dev/null
+++ b/bookstax/Views/Search/SearchView.swift
@@ -0,0 +1,254 @@
+import SwiftUI
+
+struct SearchView: View {
+ @State private var viewModel = SearchViewModel()
+
+ // Navigation destination after fetching the full object
+ enum Destination {
+ case page(PageDTO)
+ case book(BookDTO)
+ case shelf(ShelfDTO)
+ }
+
+ @State private var destination: Destination? = nil
+ @State private var isLoadingDestination = false
+ @State private var navigationActive = false
+ @State private var loadError: BookStackError? = nil
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if viewModel.query.isEmpty {
+ recentSearchesView
+ } else if viewModel.isSearching {
+ LoadingView(message: "Searching…")
+ } else if viewModel.results.isEmpty {
+ ContentUnavailableView.search(text: viewModel.query)
+ } else {
+ resultsList
+ }
+ }
+ .navigationTitle(L("search.title"))
+ .searchable(text: $viewModel.query, prompt: L("search.prompt"))
+ .onChange(of: viewModel.query) { viewModel.onQueryChanged() }
+ .toolbar {
+ if !SearchResultDTO.ContentType.allCases.isEmpty {
+ ToolbarItem(placement: .topBarTrailing) {
+ filterMenu
+ }
+ }
+ }
+ .navigationDestination(isPresented: $navigationActive) {
+ switch destination {
+ case .page(let page): PageReaderView(page: page)
+ case .book(let book): BookDetailView(book: book)
+ case .shelf(let shelf): BooksInShelfView(shelf: shelf)
+ case nil: EmptyView()
+ }
+ }
+ .overlay {
+ if isLoadingDestination {
+ ZStack {
+ Color.black.opacity(0.15).ignoresSafeArea()
+ ProgressView(L("search.opening"))
+ .padding(20)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
+ }
+ }
+ }
+ .alert(L("search.error.title"), isPresented: Binding(
+ get: { loadError != nil },
+ set: { if !$0 { loadError = nil } }
+ )) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text(loadError?.errorDescription ?? "Unknown error")
+ }
+ }
+ }
+
+ // MARK: - Results List
+
+ private var resultsList: some View {
+ List {
+ if let error = viewModel.error {
+ ErrorBanner(error: error) {
+ viewModel.onFilterChanged()
+ }
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ }
+ ForEach(viewModel.results) { result in
+ Button {
+ Task { await open(result) }
+ } label: {
+ SearchResultRow(result: result)
+ }
+ .foregroundStyle(.primary)
+ }
+ }
+ .listStyle(.insetGrouped)
+ .animation(.easeInOut, value: viewModel.results.map(\.id))
+ }
+
+ // MARK: - Open result
+
+ private func open(_ result: SearchResultDTO) async {
+ isLoadingDestination = true
+ loadError = nil
+ do {
+ switch result.type {
+ case .page:
+ let page = try await BookStackAPI.shared.fetchPage(id: result.id)
+ destination = .page(page)
+ case .book:
+ let book = try await BookStackAPI.shared.fetchBook(id: result.id)
+ destination = .book(book)
+ case .shelf:
+ let shelfDetail = try await BookStackAPI.shared.fetchShelf(id: result.id)
+ // Build a ShelfDTO from the ShelfBooksResponse for BooksInShelfView
+ let shelf = ShelfDTO(
+ id: shelfDetail.id,
+ name: shelfDetail.name,
+ slug: "",
+ description: "",
+ createdAt: Date(),
+ updatedAt: Date(),
+ cover: nil
+ )
+ destination = .shelf(shelf)
+ case .chapter:
+ // Navigate to the chapter's parent book
+ let chapter = try await BookStackAPI.shared.fetchChapter(id: result.id)
+ let book = try await BookStackAPI.shared.fetchBook(id: chapter.bookId)
+ destination = .book(book)
+ }
+ navigationActive = true
+ } catch let e as BookStackError {
+ loadError = e
+ } catch {
+ loadError = .unknown(error.localizedDescription)
+ }
+ isLoadingDestination = false
+ }
+
+ // MARK: - Recent Searches
+
+ private var recentSearchesView: some View {
+ Group {
+ if viewModel.recentSearches.isEmpty {
+ EmptyStateView(
+ systemImage: "magnifyingglass",
+ title: "Search BookStack",
+ message: "Search for pages, books, chapters, and shelves across your entire knowledge base."
+ )
+ } else {
+ List {
+ Section {
+ ForEach(viewModel.recentSearches, id: \.self) { recent in
+ Button {
+ viewModel.query = recent
+ viewModel.onQueryChanged()
+ } label: {
+ Label(recent, systemImage: "clock")
+ .foregroundStyle(.primary)
+ }
+ }
+ } header: {
+ HStack {
+ Text(L("search.recent"))
+ Spacer()
+ Button(L("search.recent.clear")) {
+ viewModel.clearRecentSearches()
+ }
+ .font(.footnote)
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ }
+ }
+ }
+
+ // MARK: - Filter Menu
+
+ private var filterMenu: some View {
+ Menu {
+ Button {
+ viewModel.selectedTypeFilter = nil
+ viewModel.onFilterChanged()
+ } label: {
+ HStack {
+ Text("All")
+ if viewModel.selectedTypeFilter == nil {
+ Image(systemName: "checkmark")
+ }
+ }
+ }
+ Divider()
+ ForEach(SearchResultDTO.ContentType.allCases, id: \.self) { type in
+ Button {
+ viewModel.selectedTypeFilter = type
+ viewModel.onFilterChanged()
+ } label: {
+ HStack {
+ Label(type.displayName, systemImage: type.systemImage)
+ if viewModel.selectedTypeFilter == type {
+ Image(systemName: "checkmark")
+ }
+ }
+ }
+ }
+ } label: {
+ Image(systemName: viewModel.selectedTypeFilter == nil
+ ? "line.3.horizontal.decrease.circle"
+ : "line.3.horizontal.decrease.circle.fill")
+ }
+ .accessibilityLabel(L("search.filter"))
+ }
+}
+
+// MARK: - Search Result Row
+
+struct SearchResultRow: View {
+ let result: SearchResultDTO
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Image(systemName: result.type.systemImage)
+ .font(.footnote)
+ .foregroundStyle(.blue)
+ .frame(width: 18)
+ .accessibilityHidden(true)
+
+ Text(result.name)
+ .font(.body.weight(.medium))
+
+ Spacer()
+
+ Text(result.type.rawValue.capitalized)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(Color(.secondarySystemBackground), in: Capsule())
+ }
+
+ if let preview = result.preview, !preview.isEmpty {
+ Text(preview)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ .padding(.leading, 26)
+ }
+ }
+ .padding(.vertical, 4)
+ .accessibilityLabel("\(result.type.rawValue.capitalized): \(result.name)")
+ .accessibilityHint(result.preview ?? "")
+ }
+}
+
+#Preview {
+ SearchView()
+}
diff --git a/bookstax/Views/Settings/SettingsView.swift b/bookstax/Views/Settings/SettingsView.swift
new file mode 100644
index 0000000..87748cc
--- /dev/null
+++ b/bookstax/Views/Settings/SettingsView.swift
@@ -0,0 +1,191 @@
+import SwiftUI
+import SafariServices
+
+struct SettingsView: View {
+ @AppStorage("onboardingComplete") private var onboardingComplete = false
+ @AppStorage("syncWiFiOnly") private var syncWiFiOnly = true
+ @AppStorage("showComments") private var showComments = true
+ @AppStorage("appTheme") private var appTheme = "system"
+ @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
+
+ private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
+ private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ // Language section
+ Section {
+ ForEach(LanguageManager.Language.allCases) { lang in
+ Button {
+ selectedLanguage = lang
+ LanguageManager.shared.set(lang)
+ } label: {
+ HStack {
+ Text(lang.flag)
+ Text(lang.displayName)
+ .foregroundStyle(.primary)
+ Spacer()
+ if selectedLanguage == lang {
+ Image(systemName: "checkmark")
+ .foregroundStyle(.blue)
+ }
+ }
+ }
+ }
+ } header: {
+ Text(L("settings.language.header"))
+ }
+
+ // Appearance section
+ Section(L("settings.appearance")) {
+ Picker(L("settings.appearance.theme"), selection: $appTheme) {
+ Text(L("settings.appearance.theme.system")).tag("system")
+ Text(L("settings.appearance.theme.light")).tag("light")
+ Text(L("settings.appearance.theme.dark")).tag("dark")
+ }
+ .pickerStyle(.segmented)
+ }
+
+ // Account section
+ Section(L("settings.account")) {
+ HStack {
+ Image(systemName: "person.circle.fill")
+ .font(.title)
+ .foregroundStyle(.blue)
+ VStack(alignment: .leading) {
+ Text(L("settings.account.connected"))
+ .font(.headline)
+ Text(serverURL)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .padding(.vertical, 4)
+
+ Button {
+ UIPasteboard.general.string = serverURL
+ } label: {
+ Label(L("settings.account.copyurl"), systemImage: "doc.on.doc")
+ }
+
+ Button(role: .destructive) {
+ showSignOutAlert = true
+ } label: {
+ Label(L("settings.account.signout"), systemImage: "rectangle.portrait.and.arrow.right")
+ }
+ }
+
+ // Reader section
+ Section(L("settings.reader")) {
+ Toggle(L("settings.reader.showcomments"), isOn: $showComments)
+ }
+
+ // Sync section
+ Section(L("settings.sync")) {
+ Toggle(L("settings.sync.wifionly"), isOn: $syncWiFiOnly)
+
+ Button {
+ Task { await syncNow() }
+ } label: {
+ HStack {
+ Label(L("settings.sync.now"), systemImage: "arrow.clockwise")
+ if isSyncing {
+ Spacer()
+ ProgressView()
+ }
+ }
+ }
+ .disabled(isSyncing)
+
+ if let lastSynced {
+ LabeledContent(L("settings.sync.lastsynced")) {
+ Text(lastSynced.bookStackFormattedWithTime)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ // About section
+ Section(L("settings.about")) {
+ LabeledContent(L("settings.about.version"), value: "\(appVersion) (\(buildNumber))")
+
+ Button {
+ showSafari = URL(string: "https://www.bookstackapp.com/docs")
+ } label: {
+ Label(L("settings.about.docs"), systemImage: "book.pages")
+ }
+
+ Button {
+ showSafari = URL(string: "https://github.com/BookStackApp/BookStack/issues")
+ } label: {
+ Label(L("settings.about.issue"), systemImage: "exclamationmark.bubble")
+ }
+
+ Text(L("settings.about.credit"))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .navigationTitle(L("settings.title"))
+ .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) {}
+ } message: {
+ Text(L("settings.signout.alert.message"))
+ }
+ .sheet(item: $showSafari) { url in
+ SafariView(url: url)
+ .ignoresSafeArea()
+ }
+ }
+ }
+
+ // MARK: - Actions
+
+ private func signOut() {
+ Task {
+ try? await KeychainService.shared.deleteCredentials()
+ UserDefaults.standard.removeObject(forKey: "serverURL")
+ UserDefaults.standard.removeObject(forKey: "lastSynced")
+ onboardingComplete = false
+ }
+ }
+
+ private func syncNow() async {
+ isSyncing = true
+ // SyncService.shared.syncAll() requires ModelContext from environment
+ // For now just update last synced date
+ try? await Task.sleep(for: .seconds(1))
+ let now = Date()
+ UserDefaults.standard.set(now, forKey: "lastSynced")
+ lastSynced = now
+ isSyncing = false
+ }
+}
+
+// MARK: - Safari View
+
+struct SafariView: UIViewControllerRepresentable {
+ let url: URL
+
+ func makeUIViewController(context: Context) -> SFSafariViewController {
+ SFSafariViewController(url: url)
+ }
+
+ func updateUIViewController(_ vc: SFSafariViewController, context: Context) {}
+}
+
+extension URL: @retroactive Identifiable {
+ public var id: String { absoluteString }
+}
+
+#Preview {
+ SettingsView()
+}
diff --git a/bookstax/Views/Shared/EmptyStateView.swift b/bookstax/Views/Shared/EmptyStateView.swift
new file mode 100644
index 0000000..4780a2e
--- /dev/null
+++ b/bookstax/Views/Shared/EmptyStateView.swift
@@ -0,0 +1,36 @@
+import SwiftUI
+
+struct EmptyStateView: View {
+ let systemImage: String
+ let title: String
+ let message: String
+ var actionTitle: String? = nil
+ var action: (() -> Void)? = nil
+
+ var body: some View {
+ ContentUnavailableView(
+ label: {
+ Label(title, systemImage: systemImage)
+ },
+ description: {
+ Text(message)
+ },
+ actions: {
+ if let actionTitle, let action {
+ Button(actionTitle, action: action)
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ )
+ }
+}
+
+#Preview {
+ EmptyStateView(
+ systemImage: "books.vertical",
+ title: "No Shelves",
+ message: "Your library is empty. Create a shelf in BookStack to get started.",
+ actionTitle: "Refresh",
+ action: {}
+ )
+}
diff --git a/bookstax/Views/Shared/ErrorBanner.swift b/bookstax/Views/Shared/ErrorBanner.swift
new file mode 100644
index 0000000..0bc0888
--- /dev/null
+++ b/bookstax/Views/Shared/ErrorBanner.swift
@@ -0,0 +1,45 @@
+import SwiftUI
+
+struct ErrorBanner: View {
+ let error: BookStackError
+ var onRetry: (() -> Void)? = nil
+ var onSettings: (() -> Void)? = nil
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.orange)
+ .font(.title3)
+
+ Text(error.errorDescription ?? "An unknown error occurred.")
+ .font(.callout)
+ .foregroundStyle(.primary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Spacer()
+
+ if case .unauthorized = error, let onSettings {
+ Button("Settings", action: onSettings)
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ } else if let onRetry {
+ Button("Retry", action: onRetry)
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ }
+ }
+ .padding()
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("Error: \(error.errorDescription ?? "Unknown error")")
+ }
+}
+
+#Preview {
+ VStack(spacing: 16) {
+ ErrorBanner(error: .networkUnavailable, onRetry: {})
+ ErrorBanner(error: .unauthorized)
+ ErrorBanner(error: .timeout, onRetry: {})
+ }
+ .padding()
+}
diff --git a/bookstax/Views/Shared/LoadingView.swift b/bookstax/Views/Shared/LoadingView.swift
new file mode 100644
index 0000000..4a58c47
--- /dev/null
+++ b/bookstax/Views/Shared/LoadingView.swift
@@ -0,0 +1,22 @@
+import SwiftUI
+
+struct LoadingView: View {
+ var message: String = "Loading..."
+
+ var body: some View {
+ VStack(spacing: 12) {
+ ProgressView()
+ .controlSize(.large)
+ Text(message)
+ .foregroundStyle(.secondary)
+ .font(.subheadline)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(message)
+ }
+}
+
+#Preview {
+ LoadingView(message: "Loading shelves...")
+}
diff --git a/bookstax/Views/Shared/OfflineBanner.swift b/bookstax/Views/Shared/OfflineBanner.swift
new file mode 100644
index 0000000..a26cd1d
--- /dev/null
+++ b/bookstax/Views/Shared/OfflineBanner.swift
@@ -0,0 +1,25 @@
+import SwiftUI
+
+struct OfflineBanner: View {
+ var body: some View {
+ HStack(spacing: 8) {
+ Image(systemName: "wifi.slash")
+ .font(.footnote.bold())
+ Text(L("offline.banner"))
+ .font(.footnote.bold())
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ .padding(.horizontal)
+ .background(Color.orange)
+ .foregroundStyle(.white)
+ .accessibilityLabel("Offline. Showing cached content.")
+ }
+}
+
+#Preview {
+ VStack {
+ OfflineBanner()
+ Spacer()
+ }
+}
diff --git a/bookstax/bookstaxApp.swift b/bookstax/bookstaxApp.swift
index 53b882c..c4a74ce 100644
--- a/bookstax/bookstaxApp.swift
+++ b/bookstax/bookstaxApp.swift
@@ -6,12 +6,48 @@
//
import SwiftUI
+import SwiftData
@main
struct bookstaxApp: App {
- var body: some Scene {
- WindowGroup {
- ContentView()
+ @AppStorage("onboardingComplete") private var onboardingComplete = false
+ @AppStorage("appTheme") private var appTheme = "system"
+
+ private var preferredColorScheme: ColorScheme? {
+ switch appTheme {
+ case "light": return .light
+ case "dark": return .dark
+ default: return nil // nil = follow system
}
}
+
+ let sharedModelContainer: ModelContainer = {
+ let schema = Schema([CachedShelf.self, CachedBook.self, CachedPage.self])
+ let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
+ do {
+ return try ModelContainer(for: schema, configurations: [config])
+ } catch {
+ fatalError("Could not create ModelContainer: \(error)")
+ }
+ }()
+
+ init() {
+ AppLog(.info, "BookStax launched", category: "App")
+ }
+
+ var body: some Scene {
+ WindowGroup {
+ Group {
+ if onboardingComplete {
+ MainTabView()
+ .environment(ConnectivityMonitor.shared)
+ } else {
+ OnboardingView()
+ }
+ }
+ .preferredColorScheme(preferredColorScheme)
+ }
+ .modelContainer(sharedModelContainer)
+ }
}
+
diff --git a/bookstax/de.lproj/Localizable.strings b/bookstax/de.lproj/Localizable.strings
new file mode 100644
index 0000000..1367dd7
--- /dev/null
+++ b/bookstax/de.lproj/Localizable.strings
@@ -0,0 +1,184 @@
+// MARK: - Onboarding
+"onboarding.language.title" = "Sprache wählen";
+"onboarding.language.subtitle" = "Du kannst die Sprache später in den Einstellungen ändern.";
+"onboarding.welcome.title" = "Willkommen bei BookStax";
+"onboarding.welcome.subtitle" = "Deine selbst gehostete Wissensdatenbank,\njetzt in deiner Tasche.";
+"onboarding.welcome.cta" = "Los geht's";
+"onboarding.server.title" = "Wo ist dein BookStack?";
+"onboarding.server.subtitle" = "Gib die Webadresse deiner BookStack-Installation ein. Das ist dieselbe URL, die du auch im Browser verwendest.";
+"onboarding.server.placeholder" = "https://wiki.meinefirma.de";
+"onboarding.server.next" = "Weiter";
+"onboarding.server.error.empty" = "Bitte gib die Adresse deines BookStack-Servers ein.";
+"onboarding.server.error.invalid" = "Das sieht nicht nach einer gültigen Webadresse aus. Versuche z.B. https://bookstack.example.com";
+"onboarding.server.warning.http" = "Unverschlüsselte Verbindung erkannt. Deine Daten könnten im Netzwerk sichtbar sein.";
+"onboarding.token.title" = "Mit API-Token verbinden";
+"onboarding.token.subtitle" = "BookStack verwendet API-Tokens für sicheren Zugriff. Du musst einen in deinem BookStack-Profil erstellen.";
+"onboarding.token.help" = "Wie bekomme ich einen Token?";
+"onboarding.token.help.steps" = "1. Öffne deine BookStack-Instanz im Browser\n2. Klicke auf dein Avatar → Profil bearbeiten\n3. Scrolle zu \"API-Tokens\" → tippe auf \"Token erstellen\"\n4. Gib einen Namen (z.B. \"Mein iPhone\") und ein Ablaufdatum ein\n5. Kopiere Token-ID und Secret — sie werden nicht erneut angezeigt\n\nHinweis: Dein Konto benötigt die Berechtigung \"Zugriff auf System-API\". Kontaktiere deinen Administrator, wenn du den Bereich API-Tokens nicht siehst.";
+"onboarding.token.id.label" = "Token-ID";
+"onboarding.token.secret.label" = "Token-Secret";
+"onboarding.token.paste" = "Aus Zwischenablage einfügen";
+"onboarding.token.verify" = "Verbindung testen";
+"onboarding.verify.ready" = "Bereit zur Prüfung";
+"onboarding.verify.reaching" = "Server wird erreicht…";
+"onboarding.verify.found" = "%@ gefunden";
+"onboarding.verify.checking" = "Zugangsdaten werden geprüft…";
+"onboarding.verify.connected" = "Verbunden mit %@";
+"onboarding.verify.server.failed" = "Server nicht erreichbar";
+"onboarding.verify.token.failed" = "Authentifizierung fehlgeschlagen";
+"onboarding.verify.phase.server" = "Server erreichen";
+"onboarding.verify.phase.token" = "Token prüfen";
+"onboarding.verify.goback" = "Zurück";
+"onboarding.verify.retry" = "Erneut versuchen";
+"onboarding.ready.title" = "Alles bereit!";
+"onboarding.ready.subtitle" = "BookStax ist mit deiner Wissensdatenbank verbunden.";
+"onboarding.ready.cta" = "Zur Bibliothek";
+"onboarding.ready.feature.library" = "Bibliothek durchsuchen";
+"onboarding.ready.feature.library.desc" = "Regale, Bücher, Kapitel und Seiten navigieren";
+"onboarding.ready.feature.search" = "Alles suchen";
+"onboarding.ready.feature.search.desc" = "Inhalte sofort finden";
+"onboarding.ready.feature.create" = "Erstellen & Bearbeiten";
+"onboarding.ready.feature.create.desc" = "Neue Seiten in Markdown schreiben";
+
+// MARK: - Tabs
+"tab.library" = "Bibliothek";
+"tab.search" = "Suche";
+"tab.create" = "Erstellen";
+"tab.settings" = "Einstellungen";
+
+// MARK: - Library
+"library.title" = "Bibliothek";
+"library.loading" = "Bibliothek wird geladen…";
+"library.empty.title" = "Keine Regale";
+"library.empty.message" = "Deine Bibliothek ist leer. Erstelle ein Regal in BookStack, um zu beginnen.";
+"library.refresh" = "Aktualisieren";
+"library.shelves" = "Regale";
+"library.updated" = "Aktualisiert %@";
+
+// MARK: - Shelf
+"shelf.loading" = "Bücher werden geladen…";
+"shelf.empty.title" = "Keine Bücher";
+"shelf.empty.message" = "Dieses Regal enthält noch keine Bücher.";
+
+// MARK: - Book
+"book.loading" = "Inhalte werden geladen…";
+"book.empty.title" = "Kein Inhalt";
+"book.empty.message" = "Dieses Buch hat noch keine Kapitel oder Seiten.";
+"book.addpage" = "Seite hinzufügen";
+"book.newpage" = "Neue Seite";
+"book.newchapter" = "Neues Kapitel";
+"book.pages" = "Seiten";
+"book.delete" = "Löschen";
+"book.open" = "Öffnen";
+"book.sharelink" = "Link teilen";
+"book.addcontent" = "Inhalt hinzufügen";
+
+// MARK: - Chapter
+"chapter.new.title" = "Neues Kapitel";
+"chapter.new.name" = "Kapitelname";
+"chapter.new.description" = "Beschreibung (optional)";
+"chapter.details" = "Kapiteldetails";
+"chapter.cancel" = "Abbrechen";
+"chapter.create" = "Erstellen";
+
+// MARK: - Page Reader
+"reader.comments" = "Kommentare (%d)";
+"reader.comments.empty" = "Noch keine Kommentare. Schreib den ersten!";
+"reader.comment.placeholder" = "Kommentar hinzufügen…";
+"reader.comment.post" = "Kommentar absenden";
+"reader.edit" = "Seite bearbeiten";
+"reader.share" = "Seite teilen";
+"reader.nocontent" = "Kein Inhalt";
+
+// MARK: - Editor
+"editor.new.title" = "Neue Seite";
+"editor.edit.title" = "Seite bearbeiten";
+"editor.title.placeholder" = "Seitentitel";
+"editor.tab.write" = "Schreiben";
+"editor.tab.preview" = "Vorschau";
+"editor.save" = "Speichern";
+"editor.cancel" = "Abbrechen";
+"editor.discard.title" = "Änderungen verwerfen?";
+"editor.discard.message" = "Deine Änderungen gehen verloren.";
+"editor.discard.confirm" = "Verwerfen";
+"editor.discard.keepediting" = "Weiter bearbeiten";
+
+// MARK: - Search
+"search.title" = "Suche";
+"search.prompt" = "Bücher, Seiten, Kapitel suchen…";
+"search.recent" = "Letzte Suchen";
+"search.recent.clear" = "Löschen";
+"search.filter" = "Suchergebnisse filtern";
+"search.opening" = "Wird geöffnet…";
+"search.error.title" = "Ergebnis konnte nicht geöffnet werden";
+
+// MARK: - New Content
+"create.title" = "Erstellen";
+"create.section" = "Was möchtest du erstellen?";
+"create.page.title" = "Neue Seite";
+"create.page.desc" = "Neue Seite in Markdown schreiben";
+"create.book.title" = "Neues Buch";
+"create.book.desc" = "Seiten in einem Buch organisieren";
+"create.shelf.title" = "Neues Regal";
+"create.shelf.desc" = "Bücher in einem Regal gruppieren";
+"create.book.name" = "Buchname";
+"create.book.details" = "Buchdetails";
+"create.book.shelf.header" = "Regal (optional)";
+"create.book.shelf.footer" = "Weise dieses Buch einem Regal zu, um deine Bibliothek zu organisieren.";
+"create.book.shelf.none" = "Keines";
+"create.book.shelf.loading" = "Regale werden geladen…";
+"create.shelf.name" = "Regalname";
+"create.shelf.details" = "Regaldetails";
+"create.page.filter.shelf" = "Nach Regal filtern (optional)";
+"create.page.book.header" = "Buch";
+"create.page.book.footer" = "Die Seite wird in diesem Buch erstellt.";
+"create.page.book.select" = "Buch auswählen…";
+"create.page.nobooks" = "Keine Bücher verfügbar";
+"create.page.nobooks.shelf" = "Keine Bücher in diesem Regal";
+"create.page.loading" = "Wird geladen…";
+"create.page.next" = "Weiter";
+"create.description" = "Beschreibung (optional)";
+"create.cancel" = "Abbrechen";
+"create.create" = "Erstellen";
+"create.loading.books" = "Bücher werden geladen…";
+"create.any.shelf" = "Beliebiges Regal";
+
+// MARK: - Settings
+"settings.title" = "Einstellungen";
+"settings.account" = "Konto";
+"settings.account.connected" = "Verbunden";
+"settings.account.copyurl" = "Server-URL kopieren";
+"settings.account.signout" = "Abmelden";
+"settings.signout.alert.title" = "Abmelden";
+"settings.signout.alert.message" = "Dadurch werden deine gespeicherten Zugangsdaten entfernt und du musst dich erneut anmelden.";
+"settings.signout.alert.confirm" = "Abmelden";
+"settings.signout.alert.cancel" = "Abbrechen";
+"settings.sync" = "Synchronisierung";
+"settings.sync.wifionly" = "Nur über WLAN synchronisieren";
+"settings.sync.now" = "Jetzt synchronisieren";
+"settings.sync.lastsynced" = "Zuletzt synchronisiert";
+"settings.about" = "Über";
+"settings.about.version" = "Version";
+"settings.about.docs" = "BookStack-Dokumentation";
+"settings.about.issue" = "Problem melden";
+"settings.about.credit" = "BookStack ist Open-Source-Software von Dan Brown.";
+"settings.language" = "Sprache";
+"settings.language.header" = "Sprache";
+
+// MARK: - Offline
+"offline.banner" = "Du bist offline – zwischengespeicherte Inhalte werden angezeigt";
+
+// MARK: - Appearance
+"settings.appearance" = "Erscheinungsbild";
+"settings.appearance.theme" = "Design";
+"settings.appearance.theme.system" = "System";
+"settings.appearance.theme.light" = "Hell";
+"settings.appearance.theme.dark" = "Dunkel";
+
+// MARK: - Reader Settings
+"settings.reader" = "Leser";
+"settings.reader.showcomments" = "Kommentare anzeigen";
+
+// MARK: - Common
+"common.ok" = "OK";
+"common.error" = "Unbekannter Fehler";
diff --git a/bookstax/en.lproj/Localizable.strings b/bookstax/en.lproj/Localizable.strings
new file mode 100644
index 0000000..43a4efa
--- /dev/null
+++ b/bookstax/en.lproj/Localizable.strings
@@ -0,0 +1,184 @@
+// MARK: - Onboarding
+"onboarding.language.title" = "Choose Your Language";
+"onboarding.language.subtitle" = "You can change this later in Settings.";
+"onboarding.welcome.title" = "Welcome to BookStax";
+"onboarding.welcome.subtitle" = "Your self-hosted knowledge base,\nnow in your pocket.";
+"onboarding.welcome.cta" = "Get Started";
+"onboarding.server.title" = "Where is your BookStack?";
+"onboarding.server.subtitle" = "Enter the web address of your BookStack installation. This is the same URL you use in your browser.";
+"onboarding.server.placeholder" = "https://wiki.mycompany.com";
+"onboarding.server.next" = "Next";
+"onboarding.server.error.empty" = "Please enter your BookStack server address.";
+"onboarding.server.error.invalid" = "That doesn't look like a valid web address. Try something like https://bookstack.example.com";
+"onboarding.server.warning.http" = "Non-encrypted connection detected. Your data may be visible on the network.";
+"onboarding.token.title" = "Connect with an API Token";
+"onboarding.token.subtitle" = "BookStack uses API tokens for secure access. You'll need to create one in your BookStack profile.";
+"onboarding.token.help" = "How do I get a token?";
+"onboarding.token.help.steps" = "1. Open your BookStack instance in a browser\n2. Click your avatar → Edit Profile\n3. Scroll to \"API Tokens\" → tap \"Create Token\"\n4. Set a name (e.g. \"My iPhone\") and expiry date\n5. Copy the Token ID and Secret — they won't be shown again\n\nNote: Your account needs the \"Access System API\" permission. Contact your admin if you don't see the API Tokens section.";
+"onboarding.token.id.label" = "Token ID";
+"onboarding.token.secret.label" = "Token Secret";
+"onboarding.token.paste" = "Paste from clipboard";
+"onboarding.token.verify" = "Verify Connection";
+"onboarding.verify.ready" = "Ready to verify";
+"onboarding.verify.reaching" = "Reaching server…";
+"onboarding.verify.found" = "Found %@";
+"onboarding.verify.checking" = "Checking credentials…";
+"onboarding.verify.connected" = "Connected to %@";
+"onboarding.verify.server.failed" = "Server unreachable";
+"onboarding.verify.token.failed" = "Authentication failed";
+"onboarding.verify.phase.server" = "Reaching server";
+"onboarding.verify.phase.token" = "Verifying token";
+"onboarding.verify.goback" = "Go Back";
+"onboarding.verify.retry" = "Try Again";
+"onboarding.ready.title" = "You're all set!";
+"onboarding.ready.subtitle" = "BookStax is connected to your knowledge base.";
+"onboarding.ready.cta" = "Open My Library";
+"onboarding.ready.feature.library" = "Browse Library";
+"onboarding.ready.feature.library.desc" = "Navigate shelves, books, chapters and pages";
+"onboarding.ready.feature.search" = "Search Everything";
+"onboarding.ready.feature.search.desc" = "Find any content instantly";
+"onboarding.ready.feature.create" = "Create & Edit";
+"onboarding.ready.feature.create.desc" = "Write new pages in Markdown";
+
+// MARK: - Tabs
+"tab.library" = "Library";
+"tab.search" = "Search";
+"tab.create" = "Create";
+"tab.settings" = "Settings";
+
+// MARK: - Library
+"library.title" = "Library";
+"library.loading" = "Loading library…";
+"library.empty.title" = "No Shelves";
+"library.empty.message" = "Your library is empty. Create a shelf in BookStack to get started.";
+"library.refresh" = "Refresh";
+"library.shelves" = "Shelves";
+"library.updated" = "Updated %@";
+
+// MARK: - Shelf
+"shelf.loading" = "Loading books…";
+"shelf.empty.title" = "No Books";
+"shelf.empty.message" = "This shelf has no books yet.";
+
+// MARK: - Book
+"book.loading" = "Loading content…";
+"book.empty.title" = "No Content";
+"book.empty.message" = "This book has no chapters or pages yet.";
+"book.addpage" = "Add Page";
+"book.newpage" = "New Page";
+"book.newchapter" = "New Chapter";
+"book.pages" = "Pages";
+"book.delete" = "Delete";
+"book.open" = "Open";
+"book.sharelink" = "Share Link";
+"book.addcontent" = "Add content";
+
+// MARK: - Chapter
+"chapter.new.title" = "New Chapter";
+"chapter.new.name" = "Chapter name";
+"chapter.new.description" = "Description (optional)";
+"chapter.details" = "Chapter Details";
+"chapter.cancel" = "Cancel";
+"chapter.create" = "Create";
+
+// MARK: - Page Reader
+"reader.comments" = "Comments (%d)";
+"reader.comments.empty" = "No comments yet. Be the first!";
+"reader.comment.placeholder" = "Add a comment…";
+"reader.comment.post" = "Post comment";
+"reader.edit" = "Edit page";
+"reader.share" = "Share page";
+"reader.nocontent" = "No content";
+
+// MARK: - Editor
+"editor.new.title" = "New Page";
+"editor.edit.title" = "Edit Page";
+"editor.title.placeholder" = "Page title";
+"editor.tab.write" = "Write";
+"editor.tab.preview" = "Preview";
+"editor.save" = "Save";
+"editor.cancel" = "Cancel";
+"editor.discard.title" = "Discard Changes?";
+"editor.discard.message" = "Your changes will be lost.";
+"editor.discard.confirm" = "Discard";
+"editor.discard.keepediting" = "Keep Editing";
+
+// MARK: - Search
+"search.title" = "Search";
+"search.prompt" = "Search books, pages, chapters…";
+"search.recent" = "Recent Searches";
+"search.recent.clear" = "Clear";
+"search.filter" = "Filter search results";
+"search.opening" = "Opening…";
+"search.error.title" = "Could not open result";
+
+// MARK: - New Content
+"create.title" = "Create";
+"create.section" = "What would you like to create?";
+"create.page.title" = "New Page";
+"create.page.desc" = "Write a new page in Markdown";
+"create.book.title" = "New Book";
+"create.book.desc" = "Organise pages into a book";
+"create.shelf.title" = "New Shelf";
+"create.shelf.desc" = "Group books into a shelf";
+"create.book.name" = "Book name";
+"create.book.details" = "Book Details";
+"create.book.shelf.header" = "Shelf (optional)";
+"create.book.shelf.footer" = "Assign this book to a shelf to keep your library organised.";
+"create.book.shelf.none" = "None";
+"create.book.shelf.loading" = "Loading shelves…";
+"create.shelf.name" = "Shelf name";
+"create.shelf.details" = "Shelf Details";
+"create.page.filter.shelf" = "Filter by Shelf (optional)";
+"create.page.book.header" = "Book";
+"create.page.book.footer" = "The page will be created inside this book.";
+"create.page.book.select" = "Select a book…";
+"create.page.nobooks" = "No books available";
+"create.page.nobooks.shelf" = "No books in this shelf";
+"create.page.loading" = "Loading…";
+"create.page.next" = "Next";
+"create.description" = "Description (optional)";
+"create.cancel" = "Cancel";
+"create.create" = "Create";
+"create.loading.books" = "Loading books…";
+"create.any.shelf" = "Any shelf";
+
+// MARK: - Settings
+"settings.title" = "Settings";
+"settings.account" = "Account";
+"settings.account.connected" = "Connected";
+"settings.account.copyurl" = "Copy Server URL";
+"settings.account.signout" = "Sign Out";
+"settings.signout.alert.title" = "Sign Out";
+"settings.signout.alert.message" = "This will remove your saved credentials and require you to sign in again.";
+"settings.signout.alert.confirm" = "Sign Out";
+"settings.signout.alert.cancel" = "Cancel";
+"settings.sync" = "Sync";
+"settings.sync.wifionly" = "Sync on Wi-Fi only";
+"settings.sync.now" = "Sync Now";
+"settings.sync.lastsynced" = "Last synced";
+"settings.about" = "About";
+"settings.about.version" = "Version";
+"settings.about.docs" = "BookStack Documentation";
+"settings.about.issue" = "Report an Issue";
+"settings.about.credit" = "BookStack is open-source software by Dan Brown.";
+"settings.language" = "Language";
+"settings.language.header" = "Language";
+
+// MARK: - Offline
+"offline.banner" = "You're offline — showing cached content";
+
+// MARK: - Appearance
+"settings.appearance" = "Appearance";
+"settings.appearance.theme" = "Theme";
+"settings.appearance.theme.system" = "System";
+"settings.appearance.theme.light" = "Light";
+"settings.appearance.theme.dark" = "Dark";
+
+// MARK: - Reader Settings
+"settings.reader" = "Reader";
+"settings.reader.showcomments" = "Show Comments";
+
+// MARK: - Common
+"common.ok" = "OK";
+"common.error" = "Unknown error";
diff --git a/bookstax/es.lproj/Localizable.strings b/bookstax/es.lproj/Localizable.strings
new file mode 100644
index 0000000..1a98b4b
--- /dev/null
+++ b/bookstax/es.lproj/Localizable.strings
@@ -0,0 +1,184 @@
+// MARK: - Onboarding
+"onboarding.language.title" = "Elige tu idioma";
+"onboarding.language.subtitle" = "Puedes cambiarlo más tarde en Ajustes.";
+"onboarding.welcome.title" = "Bienvenido a BookStax";
+"onboarding.welcome.subtitle" = "Tu base de conocimiento autoalojada,\nahora en tu bolsillo.";
+"onboarding.welcome.cta" = "Comenzar";
+"onboarding.server.title" = "¿Dónde está tu BookStack?";
+"onboarding.server.subtitle" = "Introduce la dirección web de tu instalación de BookStack. Es la misma URL que usas en el navegador.";
+"onboarding.server.placeholder" = "https://wiki.miempresa.com";
+"onboarding.server.next" = "Siguiente";
+"onboarding.server.error.empty" = "Por favor, introduce la dirección de tu servidor BookStack.";
+"onboarding.server.error.invalid" = "Eso no parece una dirección web válida. Prueba algo como https://bookstack.example.com";
+"onboarding.server.warning.http" = "Conexión sin cifrar detectada. Tus datos podrían ser visibles en la red.";
+"onboarding.token.title" = "Conectar con un token API";
+"onboarding.token.subtitle" = "BookStack usa tokens API para un acceso seguro. Deberás crear uno en tu perfil de BookStack.";
+"onboarding.token.help" = "¿Cómo obtengo un token?";
+"onboarding.token.help.steps" = "1. Abre tu instancia de BookStack en un navegador\n2. Haz clic en tu avatar → Editar perfil\n3. Desplázate a \"Tokens API\" → toca \"Crear token\"\n4. Ponle un nombre (p.ej. \"Mi iPhone\") y fecha de expiración\n5. Copia el ID y el secreto del token — no se mostrarán de nuevo\n\nNota: Tu cuenta necesita el permiso \"Acceder a la API del sistema\". Contacta a tu administrador si no ves la sección de tokens API.";
+"onboarding.token.id.label" = "ID del token";
+"onboarding.token.secret.label" = "Secreto del token";
+"onboarding.token.paste" = "Pegar desde el portapapeles";
+"onboarding.token.verify" = "Verificar conexión";
+"onboarding.verify.ready" = "Listo para verificar";
+"onboarding.verify.reaching" = "Contactando servidor…";
+"onboarding.verify.found" = "%@ encontrado";
+"onboarding.verify.checking" = "Verificando credenciales…";
+"onboarding.verify.connected" = "Conectado a %@";
+"onboarding.verify.server.failed" = "Servidor no disponible";
+"onboarding.verify.token.failed" = "Error de autenticación";
+"onboarding.verify.phase.server" = "Contactar servidor";
+"onboarding.verify.phase.token" = "Verificar token";
+"onboarding.verify.goback" = "Volver";
+"onboarding.verify.retry" = "Reintentar";
+"onboarding.ready.title" = "¡Todo listo!";
+"onboarding.ready.subtitle" = "BookStax está conectado a tu base de conocimiento.";
+"onboarding.ready.cta" = "Abrir mi biblioteca";
+"onboarding.ready.feature.library" = "Explorar biblioteca";
+"onboarding.ready.feature.library.desc" = "Navega estantes, libros, capítulos y páginas";
+"onboarding.ready.feature.search" = "Buscar todo";
+"onboarding.ready.feature.search.desc" = "Encuentra cualquier contenido al instante";
+"onboarding.ready.feature.create" = "Crear y editar";
+"onboarding.ready.feature.create.desc" = "Escribe nuevas páginas en Markdown";
+
+// MARK: - Tabs
+"tab.library" = "Biblioteca";
+"tab.search" = "Búsqueda";
+"tab.create" = "Crear";
+"tab.settings" = "Ajustes";
+
+// MARK: - Library
+"library.title" = "Biblioteca";
+"library.loading" = "Cargando biblioteca…";
+"library.empty.title" = "Sin estantes";
+"library.empty.message" = "Tu biblioteca está vacía. Crea un estante en BookStack para empezar.";
+"library.refresh" = "Actualizar";
+"library.shelves" = "Estantes";
+"library.updated" = "Actualizado %@";
+
+// MARK: - Shelf
+"shelf.loading" = "Cargando libros…";
+"shelf.empty.title" = "Sin libros";
+"shelf.empty.message" = "Este estante aún no tiene libros.";
+
+// MARK: - Book
+"book.loading" = "Cargando contenido…";
+"book.empty.title" = "Sin contenido";
+"book.empty.message" = "Este libro aún no tiene capítulos ni páginas.";
+"book.addpage" = "Añadir página";
+"book.newpage" = "Nueva página";
+"book.newchapter" = "Nuevo capítulo";
+"book.pages" = "Páginas";
+"book.delete" = "Eliminar";
+"book.open" = "Abrir";
+"book.sharelink" = "Compartir enlace";
+"book.addcontent" = "Añadir contenido";
+
+// MARK: - Chapter
+"chapter.new.title" = "Nuevo capítulo";
+"chapter.new.name" = "Nombre del capítulo";
+"chapter.new.description" = "Descripción (opcional)";
+"chapter.details" = "Detalles del capítulo";
+"chapter.cancel" = "Cancelar";
+"chapter.create" = "Crear";
+
+// MARK: - Page Reader
+"reader.comments" = "Comentarios (%d)";
+"reader.comments.empty" = "Aún no hay comentarios. ¡Sé el primero!";
+"reader.comment.placeholder" = "Añadir un comentario…";
+"reader.comment.post" = "Publicar comentario";
+"reader.edit" = "Editar página";
+"reader.share" = "Compartir página";
+"reader.nocontent" = "Sin contenido";
+
+// MARK: - Editor
+"editor.new.title" = "Nueva página";
+"editor.edit.title" = "Editar página";
+"editor.title.placeholder" = "Título de la página";
+"editor.tab.write" = "Escribir";
+"editor.tab.preview" = "Vista previa";
+"editor.save" = "Guardar";
+"editor.cancel" = "Cancelar";
+"editor.discard.title" = "¿Descartar cambios?";
+"editor.discard.message" = "Se perderán todos tus cambios.";
+"editor.discard.confirm" = "Descartar";
+"editor.discard.keepediting" = "Seguir editando";
+
+// MARK: - Search
+"search.title" = "Búsqueda";
+"search.prompt" = "Buscar libros, páginas, capítulos…";
+"search.recent" = "Búsquedas recientes";
+"search.recent.clear" = "Borrar";
+"search.filter" = "Filtrar resultados";
+"search.opening" = "Abriendo…";
+"search.error.title" = "No se pudo abrir el resultado";
+
+// MARK: - New Content
+"create.title" = "Crear";
+"create.section" = "¿Qué deseas crear?";
+"create.page.title" = "Nueva página";
+"create.page.desc" = "Escribe una nueva página en Markdown";
+"create.book.title" = "Nuevo libro";
+"create.book.desc" = "Organiza páginas en un libro";
+"create.shelf.title" = "Nuevo estante";
+"create.shelf.desc" = "Agrupa libros en un estante";
+"create.book.name" = "Nombre del libro";
+"create.book.details" = "Detalles del libro";
+"create.book.shelf.header" = "Estante (opcional)";
+"create.book.shelf.footer" = "Asigna este libro a un estante para mantener organizada tu biblioteca.";
+"create.book.shelf.none" = "Ninguno";
+"create.book.shelf.loading" = "Cargando estantes…";
+"create.shelf.name" = "Nombre del estante";
+"create.shelf.details" = "Detalles del estante";
+"create.page.filter.shelf" = "Filtrar por estante (opcional)";
+"create.page.book.header" = "Libro";
+"create.page.book.footer" = "La página se creará dentro de este libro.";
+"create.page.book.select" = "Seleccionar un libro…";
+"create.page.nobooks" = "No hay libros disponibles";
+"create.page.nobooks.shelf" = "No hay libros en este estante";
+"create.page.loading" = "Cargando…";
+"create.page.next" = "Siguiente";
+"create.description" = "Descripción (opcional)";
+"create.cancel" = "Cancelar";
+"create.create" = "Crear";
+"create.loading.books" = "Cargando libros…";
+"create.any.shelf" = "Cualquier estante";
+
+// MARK: - Settings
+"settings.title" = "Ajustes";
+"settings.account" = "Cuenta";
+"settings.account.connected" = "Conectado";
+"settings.account.copyurl" = "Copiar URL del servidor";
+"settings.account.signout" = "Cerrar sesión";
+"settings.signout.alert.title" = "Cerrar sesión";
+"settings.signout.alert.message" = "Esto eliminará tus credenciales guardadas y requerirá que inicies sesión de nuevo.";
+"settings.signout.alert.confirm" = "Cerrar sesión";
+"settings.signout.alert.cancel" = "Cancelar";
+"settings.sync" = "Sincronización";
+"settings.sync.wifionly" = "Sincronizar solo por Wi-Fi";
+"settings.sync.now" = "Sincronizar ahora";
+"settings.sync.lastsynced" = "Última sincronización";
+"settings.about" = "Acerca de";
+"settings.about.version" = "Versión";
+"settings.about.docs" = "Documentación de BookStack";
+"settings.about.issue" = "Reportar un problema";
+"settings.about.credit" = "BookStack es software de código abierto de Dan Brown.";
+"settings.language" = "Idioma";
+"settings.language.header" = "Idioma";
+
+// MARK: - Offline
+"offline.banner" = "Estás sin conexión — mostrando contenido en caché";
+
+// MARK: - Appearance
+"settings.appearance" = "Apariencia";
+"settings.appearance.theme" = "Tema";
+"settings.appearance.theme.system" = "Sistema";
+"settings.appearance.theme.light" = "Claro";
+"settings.appearance.theme.dark" = "Oscuro";
+
+// MARK: - Reader Settings
+"settings.reader" = "Lector";
+"settings.reader.showcomments" = "Mostrar comentarios";
+
+// MARK: - Common
+"common.ok" = "Aceptar";
+"common.error" = "Error desconocido";