First Commit
This commit is contained in:
@@ -91,6 +91,8 @@
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
de,
|
||||
es,
|
||||
);
|
||||
mainGroup = 261299CD2F6C686D00EC1C97;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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: "<h1>Installation</h1><p>Welcome to the guide. Follow these steps to get started.</p><ul><li>Install Xcode</li><li>Clone the repository</li><li>Run the app</li></ul>",
|
||||
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: "<h1>Configuration</h1><p>Configure your environment.</p>", 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: "<h1>Deployment</h1><p>Deploy to production.</p>", 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: "<p>Great documentation! Very helpful.</p>",
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<T: Codable & Sendable>: 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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<T: Decodable & Sendable>(
|
||||
endpoint: String,
|
||||
method: String = "GET"
|
||||
) async throws -> T {
|
||||
try await performRequest(endpoint: endpoint, method: method, bodyData: nil)
|
||||
}
|
||||
|
||||
// MARK: - Core Request (with body)
|
||||
|
||||
func request<T: Decodable & Sendable, Body: Encodable & Sendable>(
|
||||
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<T: Decodable & Sendable>(
|
||||
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<ShelfDTO> = 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<BookDTO> = 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<ChapterDTO> = 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<PageDTO> = 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<CommentDTO> = 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<BookDTO> = 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<UserDTO> = 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]
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<CachedShelf>(
|
||||
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<CachedBook>(
|
||||
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<CachedPage>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Void, Never>?
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -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 = """
|
||||
<!DOCTYPE html><html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { font-family: -apple-system; font-size: 16px; line-height: 1.6; padding: 16px;
|
||||
background: \(bg); color: \(fg); }
|
||||
pre { background: \(codeBg); padding: 12px; border-radius: 8px; overflow-x: auto; }
|
||||
code { font-family: "SF Mono", monospace; font-size: 14px; background: \(codeBg);
|
||||
padding: 2px 4px; border-radius: 4px; }
|
||||
pre code { background: transparent; padding: 0; }
|
||||
blockquote { border-left: 3px solid #0a84ff; padding-left: 12px; color: #8e8e93; margin-left: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>\(html)</body></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: "<pre><code>$1</code></pre>",
|
||||
options: .regularExpression)
|
||||
// Inline code
|
||||
html = html.replacingOccurrences(of: "`([^`]+)`", with: "<code>$1</code>",
|
||||
options: .regularExpression)
|
||||
// Bold + italic
|
||||
html = html.replacingOccurrences(of: "\\*\\*\\*(.+?)\\*\\*\\*", with: "<strong><em>$1</em></strong>",
|
||||
options: .regularExpression)
|
||||
html = html.replacingOccurrences(of: "\\*\\*(.+?)\\*\\*", with: "<strong>$1</strong>",
|
||||
options: .regularExpression)
|
||||
html = html.replacingOccurrences(of: "\\*(.+?)\\*", with: "<em>$1</em>",
|
||||
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: "<h\(h)>$1</h\(h)>",
|
||||
options: .regularExpression)
|
||||
}
|
||||
// Horizontal rule
|
||||
html = html.replacingOccurrences(of: "(?m)^---$", with: "<hr>",
|
||||
options: .regularExpression)
|
||||
// Unordered list items
|
||||
html = html.replacingOccurrences(of: "(?m)^[\\-\\*] (.+)$", with: "<li>$1</li>",
|
||||
options: .regularExpression)
|
||||
// Blockquote
|
||||
html = html.replacingOccurrences(of: "(?m)^> (.+)$", with: "<blockquote>$1</blockquote>",
|
||||
options: .regularExpression)
|
||||
// Paragraphs: double newlines become <br><br>
|
||||
html = html.replacingOccurrences(of: "\n\n", with: "<br><br>")
|
||||
return html
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("New Page") {
|
||||
PageEditorView(mode: .create(bookId: 1))
|
||||
}
|
||||
|
||||
#Preview("Edit Page") {
|
||||
PageEditorView(mode: .edit(page: .mock))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 ?? "<p><em>\(L("reader.nocontent"))</em></p>")
|
||||
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 """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
padding: 16px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background-color: \(bg);
|
||||
color: \(fg);
|
||||
}
|
||||
img { max-width: 100%; height: auto; border-radius: 6px; }
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
padding: 14px;
|
||||
background: \(codeBg);
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
code {
|
||||
font-family: "SF Mono", "Menlo", monospace;
|
||||
font-size: 14px;
|
||||
background: \(codeBg);
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
pre code { background: transparent; padding: 0; }
|
||||
a { color: #0a84ff; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
blockquote {
|
||||
border-left: 4px solid #0a84ff;
|
||||
margin: 16px 0;
|
||||
padding: 8px 16px;
|
||||
color: #8e8e93;
|
||||
}
|
||||
table { border-collapse: collapse; width: 100%; margin: 16px 0; }
|
||||
th, td { border: 1px solid \(border); padding: 10px 12px; text-align: left; }
|
||||
th { background: \(codeBg); font-weight: 600; }
|
||||
h1, h2, h3, h4, h5, h6 { line-height: 1.3; }
|
||||
hr { border: none; border-top: 1px solid \(border); margin: 24px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>\(content)</body>
|
||||
</html>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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: {}
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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...")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user