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.

", + 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";