Tags hinzugefügt, Flow angepasst
This commit is contained in:
@@ -62,14 +62,15 @@ extension PageDTO {
|
|||||||
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",
|
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,
|
priority: 1,
|
||||||
draftStatus: false,
|
draftStatus: false,
|
||||||
|
tags: [TagDTO(name: "status", value: "draft", order: 0)],
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
updatedAt: Date()
|
updatedAt: Date()
|
||||||
)
|
)
|
||||||
|
|
||||||
static let mockList: [PageDTO] = [
|
static let mockList: [PageDTO] = [
|
||||||
mock,
|
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: 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, tags: [], 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())
|
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, tags: [], createdAt: Date(), updatedAt: Date())
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,14 +81,15 @@ extension SearchResultDTO {
|
|||||||
slug: "installation",
|
slug: "installation",
|
||||||
type: .page,
|
type: .page,
|
||||||
url: "/books/1/page/installation",
|
url: "/books/1/page/installation",
|
||||||
preview: "Welcome to the guide. Follow these steps to get started..."
|
preview: "Welcome to the guide. Follow these steps to get started...",
|
||||||
|
tags: [TagDTO(name: "status", value: "draft", order: 0)]
|
||||||
)
|
)
|
||||||
|
|
||||||
static let mockList: [SearchResultDTO] = [
|
static let mockList: [SearchResultDTO] = [
|
||||||
mock,
|
mock,
|
||||||
SearchResultDTO(id: 2, name: "iOS Development Guide", slug: "ios-dev", type: .book, url: "/books/2", preview: nil),
|
SearchResultDTO(id: 2, name: "iOS Development Guide", slug: "ios-dev", type: .book, url: "/books/2", preview: nil, tags: []),
|
||||||
SearchResultDTO(id: 3, name: "Getting Started", slug: "getting-started", type: .chapter, url: "/books/1/chapter/getting-started", preview: nil),
|
SearchResultDTO(id: 3, name: "Getting Started", slug: "getting-started", type: .chapter, url: "/books/1/chapter/getting-started", preview: nil, tags: []),
|
||||||
SearchResultDTO(id: 4, name: "Engineering Docs", slug: "engineering-docs", type: .shelf, url: "/shelves/engineering-docs", preview: nil)
|
SearchResultDTO(id: 4, name: "Engineering Docs", slug: "engineering-docs", type: .shelf, url: "/shelves/engineering-docs", preview: nil, tags: [])
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,21 @@ nonisolated struct CoverDTO: Codable, Sendable, Hashable {
|
|||||||
let url: String
|
let url: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Tag
|
||||||
|
|
||||||
|
nonisolated struct TagDTO: Codable, Sendable, Hashable, Identifiable {
|
||||||
|
let name: String
|
||||||
|
let value: String
|
||||||
|
let order: Int
|
||||||
|
|
||||||
|
// Synthesised stable identity: tags have no server-side id in the list endpoint
|
||||||
|
var id: String { "\(name):\(value)" }
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name, value, order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated struct PaginatedResponse<T: Codable & Sendable>: Codable, Sendable {
|
nonisolated struct PaginatedResponse<T: Codable & Sendable>: Codable, Sendable {
|
||||||
let data: [T]
|
let data: [T]
|
||||||
let total: Int
|
let total: Int
|
||||||
@@ -87,17 +102,43 @@ nonisolated struct PageDTO: Codable, Sendable, Identifiable, Hashable {
|
|||||||
let markdown: String?
|
let markdown: String?
|
||||||
let priority: Int
|
let priority: Int
|
||||||
let draftStatus: Bool
|
let draftStatus: Bool
|
||||||
|
let tags: [TagDTO]
|
||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
let updatedAt: Date
|
let updatedAt: Date
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, name, slug, html, markdown, priority
|
case id, name, slug, html, markdown, priority, tags
|
||||||
case bookId = "book_id"
|
case bookId = "book_id"
|
||||||
case chapterId = "chapter_id"
|
case chapterId = "chapter_id"
|
||||||
case draftStatus = "draft"
|
case draftStatus = "draft"
|
||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
case updatedAt = "updated_at"
|
case updatedAt = "updated_at"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(id: Int, bookId: Int, chapterId: Int?, name: String, slug: String,
|
||||||
|
html: String?, markdown: String?, priority: Int, draftStatus: Bool,
|
||||||
|
tags: [TagDTO] = [], createdAt: Date, updatedAt: Date) {
|
||||||
|
self.id = id; self.bookId = bookId; self.chapterId = chapterId
|
||||||
|
self.name = name; self.slug = slug; self.html = html; self.markdown = markdown
|
||||||
|
self.priority = priority; self.draftStatus = draftStatus; self.tags = tags
|
||||||
|
self.createdAt = createdAt; self.updatedAt = updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
bookId = try c.decode(Int.self, forKey: .bookId)
|
||||||
|
chapterId = try c.decodeIfPresent(Int.self, forKey: .chapterId)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
slug = try c.decode(String.self, forKey: .slug)
|
||||||
|
html = try c.decodeIfPresent(String.self, forKey: .html)
|
||||||
|
markdown = try c.decodeIfPresent(String.self, forKey: .markdown)
|
||||||
|
priority = try c.decode(Int.self, forKey: .priority)
|
||||||
|
draftStatus = try c.decode(Bool.self, forKey: .draftStatus)
|
||||||
|
tags = (try? c.decodeIfPresent([TagDTO].self, forKey: .tags)) ?? []
|
||||||
|
createdAt = try c.decode(Date.self, forKey: .createdAt)
|
||||||
|
updatedAt = try c.decode(Date.self, forKey: .updatedAt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Search
|
// MARK: - Search
|
||||||
@@ -109,6 +150,23 @@ nonisolated struct SearchResultDTO: Codable, Sendable, Identifiable, Hashable {
|
|||||||
let type: ContentType
|
let type: ContentType
|
||||||
let url: String
|
let url: String
|
||||||
let preview: String?
|
let preview: String?
|
||||||
|
let tags: [TagDTO]
|
||||||
|
|
||||||
|
init(id: Int, name: String, slug: String, type: ContentType, url: String, preview: String?, tags: [TagDTO] = []) {
|
||||||
|
self.id = id; self.name = name; self.slug = slug; self.type = type
|
||||||
|
self.url = url; self.preview = preview; self.tags = tags
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
slug = try c.decode(String.self, forKey: .slug)
|
||||||
|
type = try c.decode(ContentType.self, forKey: .type)
|
||||||
|
url = try c.decode(String.self, forKey: .url)
|
||||||
|
preview = try c.decodeIfPresent(String.self, forKey: .preview)
|
||||||
|
tags = (try? c.decodeIfPresent([TagDTO].self, forKey: .tags)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
enum ContentType: String, Codable, Sendable, CaseIterable {
|
enum ContentType: String, Codable, Sendable, CaseIterable {
|
||||||
case page, book, chapter, shelf
|
case page, book, chapter, shelf
|
||||||
@@ -138,6 +196,13 @@ nonisolated struct SearchResponseDTO: Codable, Sendable {
|
|||||||
let total: Int
|
let total: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Tag List (from /api/tags)
|
||||||
|
|
||||||
|
nonisolated struct TagListResponseDTO: Codable, Sendable {
|
||||||
|
let data: [TagDTO]
|
||||||
|
let total: Int
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Comment
|
// MARK: - Comment
|
||||||
|
|
||||||
nonisolated struct CommentDTO: Codable, Sendable, Identifiable, Hashable {
|
nonisolated struct CommentDTO: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
|||||||
@@ -254,43 +254,64 @@ actor BookStackAPI {
|
|||||||
return try await request(endpoint: "pages/\(id)")
|
return try await request(endpoint: "pages/\(id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func createPage(bookId: Int, chapterId: Int? = nil, name: String, markdown: String) async throws -> PageDTO {
|
func createPage(bookId: Int, chapterId: Int? = nil, name: String, markdown: String, tags: [TagDTO] = []) async throws -> PageDTO {
|
||||||
|
struct TagBody: Encodable, Sendable {
|
||||||
|
let name: String
|
||||||
|
let value: String
|
||||||
|
}
|
||||||
struct Body: Encodable, Sendable {
|
struct Body: Encodable, Sendable {
|
||||||
let bookId: Int
|
let bookId: Int
|
||||||
let chapterId: Int?
|
let chapterId: Int?
|
||||||
let name: String
|
let name: String
|
||||||
let markdown: String
|
let markdown: String
|
||||||
|
let tags: [TagBody]
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case bookId = "book_id"
|
case bookId = "book_id"
|
||||||
case chapterId = "chapter_id"
|
case chapterId = "chapter_id"
|
||||||
case name, markdown
|
case name, markdown, tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return try await request(endpoint: "pages", method: "POST",
|
return try await request(endpoint: "pages", method: "POST",
|
||||||
body: Body(bookId: bookId, chapterId: chapterId, name: name, markdown: markdown))
|
body: Body(bookId: bookId, chapterId: chapterId, name: name, markdown: markdown,
|
||||||
|
tags: tags.map { TagBody(name: $0.name, value: $0.value) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
func updatePage(id: Int, name: String, markdown: String) async throws -> PageDTO {
|
func updatePage(id: Int, name: String, markdown: String, tags: [TagDTO] = []) async throws -> PageDTO {
|
||||||
|
struct TagBody: Encodable, Sendable {
|
||||||
|
let name: String
|
||||||
|
let value: String
|
||||||
|
}
|
||||||
struct Body: Encodable, Sendable {
|
struct Body: Encodable, Sendable {
|
||||||
let name: String
|
let name: String
|
||||||
let markdown: String
|
let markdown: String
|
||||||
|
let tags: [TagBody]
|
||||||
}
|
}
|
||||||
return try await request(endpoint: "pages/\(id)", method: "PUT",
|
return try await request(endpoint: "pages/\(id)", method: "PUT",
|
||||||
body: Body(name: name, markdown: markdown))
|
body: Body(name: name, markdown: markdown,
|
||||||
|
tags: tags.map { TagBody(name: $0.name, value: $0.value) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
func deletePage(id: Int) async throws {
|
func deletePage(id: Int) async throws {
|
||||||
let _: EmptyResponse = try await request(endpoint: "pages/\(id)", method: "DELETE")
|
let _: EmptyResponse = try await request(endpoint: "pages/\(id)", method: "DELETE")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Tags
|
||||||
|
|
||||||
|
func fetchTags(count: Int = 200) async throws -> [TagDTO] {
|
||||||
|
let response: TagListResponseDTO = try await request(
|
||||||
|
endpoint: "tags?count=\(count)&sort=+name"
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Search
|
// MARK: - Search
|
||||||
|
|
||||||
func search(query: String, type: SearchResultDTO.ContentType? = nil) async throws -> SearchResponseDTO {
|
func search(query: String, type: SearchResultDTO.ContentType? = nil, tag: String? = nil) async throws -> SearchResponseDTO {
|
||||||
var queryString = "search?query=\(query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query)"
|
var q = query
|
||||||
if let type {
|
if let type { q += " [type:\(type.rawValue)]" }
|
||||||
queryString += "%20[type:\(type.rawValue)]"
|
if let tag { q += " [tag:\(tag)]" }
|
||||||
}
|
let encoded = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? q
|
||||||
return try await request(endpoint: queryString)
|
return try await request(endpoint: "search?query=\(encoded)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Comments
|
// MARK: - Comments
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ final class PageEditorViewModel {
|
|||||||
case failed(String)
|
case failed(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
let mode: Mode
|
var mode: Mode
|
||||||
var title: String = ""
|
var title: String = ""
|
||||||
var markdownContent: String = ""
|
var markdownContent: String = ""
|
||||||
var activeTab: EditorTab = .write
|
var activeTab: EditorTab = .write
|
||||||
@@ -32,13 +32,20 @@ final class PageEditorViewModel {
|
|||||||
/// Set by the view after a successful upload so the markdown can be inserted at the cursor
|
/// Set by the view after a successful upload so the markdown can be inserted at the cursor
|
||||||
var pendingImageMarkdown: String? = nil
|
var pendingImageMarkdown: String? = nil
|
||||||
|
|
||||||
|
// MARK: - Tags
|
||||||
|
var tags: [TagDTO] = []
|
||||||
|
var availableTags: [TagDTO] = []
|
||||||
|
var isLoadingTags: Bool = false
|
||||||
|
|
||||||
|
// Snapshot of content at last save (or initial load), used to detect unsaved changes
|
||||||
|
private var lastSavedTitle: String = ""
|
||||||
|
private var lastSavedMarkdown: String = ""
|
||||||
|
private var lastSavedTags: [TagDTO] = []
|
||||||
|
|
||||||
var hasUnsavedChanges: Bool {
|
var hasUnsavedChanges: Bool {
|
||||||
switch mode {
|
title != lastSavedTitle
|
||||||
case .create:
|
|| markdownContent != lastSavedMarkdown
|
||||||
return !title.isEmpty || !markdownContent.isEmpty
|
|| tags != lastSavedTags
|
||||||
case .edit(let page):
|
|
||||||
return title != page.name || markdownContent != (page.markdown ?? "")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init(mode: Mode) {
|
init(mode: Mode) {
|
||||||
@@ -46,7 +53,36 @@ final class PageEditorViewModel {
|
|||||||
if case .edit(let page) = mode {
|
if case .edit(let page) = mode {
|
||||||
title = page.name
|
title = page.name
|
||||||
markdownContent = page.markdown ?? ""
|
markdownContent = page.markdown ?? ""
|
||||||
|
tags = page.tags
|
||||||
}
|
}
|
||||||
|
// Snapshot the initial state so "no changes yet" returns false
|
||||||
|
lastSavedTitle = title
|
||||||
|
lastSavedMarkdown = markdownContent
|
||||||
|
lastSavedTags = tags
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAvailableTags() async {
|
||||||
|
isLoadingTags = true
|
||||||
|
do {
|
||||||
|
availableTags = try await BookStackAPI.shared.fetchTags()
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — autocomplete just won't show suggestions
|
||||||
|
AppLog(.warning, "Could not load tags: \(error.localizedDescription)", category: "Editor")
|
||||||
|
}
|
||||||
|
isLoadingTags = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func addTag(name: String, value: String = "") {
|
||||||
|
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedName.isEmpty else { return }
|
||||||
|
// Avoid duplicates
|
||||||
|
guard !tags.contains(where: { $0.name == trimmedName && $0.value == trimmedValue }) else { return }
|
||||||
|
tags.append(TagDTO(name: trimmedName, value: trimmedValue, order: tags.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeTag(_ tag: TagDTO) {
|
||||||
|
tags.removeAll { $0.id == tag.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Save
|
// MARK: - Save
|
||||||
@@ -60,22 +96,31 @@ final class PageEditorViewModel {
|
|||||||
switch mode {
|
switch mode {
|
||||||
case .create(let bookId, let chapterId):
|
case .create(let bookId, let chapterId):
|
||||||
AppLog(.info, "Creating page '\(title)' in book \(bookId)", category: "Editor")
|
AppLog(.info, "Creating page '\(title)' in book \(bookId)", category: "Editor")
|
||||||
savedPage = try await BookStackAPI.shared.createPage(
|
let created = try await BookStackAPI.shared.createPage(
|
||||||
bookId: bookId,
|
bookId: bookId,
|
||||||
chapterId: chapterId,
|
chapterId: chapterId,
|
||||||
name: title,
|
name: title,
|
||||||
markdown: markdownContent
|
markdown: markdownContent,
|
||||||
|
tags: tags
|
||||||
)
|
)
|
||||||
AppLog(.info, "Page '\(title)' created (id: \(savedPage?.id ?? -1))", category: "Editor")
|
savedPage = created
|
||||||
|
// Switch to edit mode so subsequent saves update rather than duplicate
|
||||||
|
mode = .edit(page: created)
|
||||||
|
AppLog(.info, "Page '\(title)' created (id: \(created.id))", category: "Editor")
|
||||||
case .edit(let page):
|
case .edit(let page):
|
||||||
AppLog(.info, "Saving edits to page '\(title)' (id: \(page.id))", category: "Editor")
|
AppLog(.info, "Saving edits to page '\(title)' (id: \(page.id))", category: "Editor")
|
||||||
savedPage = try await BookStackAPI.shared.updatePage(
|
savedPage = try await BookStackAPI.shared.updatePage(
|
||||||
id: page.id,
|
id: page.id,
|
||||||
name: title,
|
name: title,
|
||||||
markdown: markdownContent
|
markdown: markdownContent,
|
||||||
|
tags: tags
|
||||||
)
|
)
|
||||||
AppLog(.info, "Page '\(title)' saved successfully", category: "Editor")
|
AppLog(.info, "Page '\(title)' saved successfully", category: "Editor")
|
||||||
}
|
}
|
||||||
|
// Update snapshot so closing immediately after saving doesn't trigger the alert
|
||||||
|
lastSavedTitle = title
|
||||||
|
lastSavedMarkdown = markdownContent
|
||||||
|
lastSavedTags = tags
|
||||||
} catch let e as BookStackError {
|
} catch let e as BookStackError {
|
||||||
AppLog(.error, "Save failed for '\(title)': \(e.localizedDescription)", category: "Editor")
|
AppLog(.error, "Save failed for '\(title)': \(e.localizedDescription)", category: "Editor")
|
||||||
saveError = e
|
saveError = e
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ final class SearchViewModel {
|
|||||||
var isSearching: Bool = false
|
var isSearching: Bool = false
|
||||||
var error: BookStackError? = nil
|
var error: BookStackError? = nil
|
||||||
var selectedTypeFilter: SearchResultDTO.ContentType? = nil
|
var selectedTypeFilter: SearchResultDTO.ContentType? = nil
|
||||||
|
var selectedTagFilter: String? = nil
|
||||||
|
var availableTags: [TagDTO] = []
|
||||||
|
var isLoadingTags: Bool = false
|
||||||
|
|
||||||
var recentSearches: [String] {
|
var recentSearches: [String] {
|
||||||
get { UserDefaults.standard.stringArray(forKey: "recentSearches") ?? [] }
|
get { UserDefaults.standard.stringArray(forKey: "recentSearches") ?? [] }
|
||||||
@@ -57,15 +60,31 @@ final class SearchViewModel {
|
|||||||
recentSearches = []
|
recentSearches = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadAvailableTags() async {
|
||||||
|
guard availableTags.isEmpty else { return }
|
||||||
|
isLoadingTags = true
|
||||||
|
do {
|
||||||
|
availableTags = try await BookStackAPI.shared.fetchTags()
|
||||||
|
} catch {
|
||||||
|
AppLog(.warning, "Could not load tags for search: \(error.localizedDescription)", category: "Search")
|
||||||
|
}
|
||||||
|
isLoadingTags = false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private func performSearch() async {
|
private func performSearch() async {
|
||||||
isSearching = true
|
isSearching = true
|
||||||
error = nil
|
error = nil
|
||||||
let filter = selectedTypeFilter.map { " [type:\($0.rawValue)]" } ?? ""
|
let typeLabel = selectedTypeFilter.map { " [type:\($0.rawValue)]" } ?? ""
|
||||||
AppLog(.info, "Search: \"\(query)\"\(filter)", category: "Search")
|
let tagLabel = selectedTagFilter.map { " [tag:\($0)]" } ?? ""
|
||||||
|
AppLog(.info, "Search: \"\(query)\"\(typeLabel)\(tagLabel)", category: "Search")
|
||||||
do {
|
do {
|
||||||
let response = try await BookStackAPI.shared.search(query: query, type: selectedTypeFilter)
|
let response = try await BookStackAPI.shared.search(
|
||||||
|
query: query,
|
||||||
|
type: selectedTypeFilter,
|
||||||
|
tag: selectedTagFilter
|
||||||
|
)
|
||||||
results = response.data
|
results = response.data
|
||||||
AppLog(.info, "Search returned \(results.count) result(s) for \"\(query)\"", category: "Search")
|
AppLog(.info, "Search returned \(results.count) result(s) for \"\(query)\"", category: "Search")
|
||||||
addToRecent(query)
|
addToRecent(query)
|
||||||
|
|||||||
@@ -63,131 +63,29 @@ struct MarkdownTextEditor: UIViewRepresentable {
|
|||||||
struct PageEditorView: View {
|
struct PageEditorView: View {
|
||||||
@State private var viewModel: PageEditorViewModel
|
@State private var viewModel: PageEditorViewModel
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.accentTheme) private var theme
|
||||||
@State private var showDiscardAlert = false
|
@State private var showDiscardAlert = false
|
||||||
|
@State private var showSavedConfirmation = false
|
||||||
/// Reference to the underlying UITextView for formatting operations
|
/// Reference to the underlying UITextView for formatting operations
|
||||||
@State private var textView: UITextView? = nil
|
@State private var textView: UITextView? = nil
|
||||||
@State private var imagePickerItem: PhotosPickerItem? = nil
|
@State private var imagePickerItem: PhotosPickerItem? = nil
|
||||||
|
@State private var showTagEditor = false
|
||||||
|
|
||||||
init(mode: PageEditorViewModel.Mode) {
|
init(mode: PageEditorViewModel.Mode) {
|
||||||
_viewModel = State(initialValue: PageEditorViewModel(mode: mode))
|
_viewModel = State(initialValue: PageEditorViewModel(mode: mode))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
editorContent
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Title field — prominent, borderless, single bottom rule
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
TextField(L("editor.title.placeholder"), text: $viewModel.title)
|
|
||||||
.font(.system(size: 22, weight: .bold))
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 14)
|
|
||||||
.padding(.bottom, 10)
|
|
||||||
|
|
||||||
Rectangle()
|
|
||||||
.fill(Color(.separator))
|
|
||||||
.frame(height: 0.5)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content area
|
|
||||||
if viewModel.activeTab == .write {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
MarkdownTextEditor(text: $viewModel.markdownContent) { tv in
|
|
||||||
textView = tv
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image upload progress
|
|
||||||
if case .uploading = viewModel.imageUploadState {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
Text(L("editor.image.uploading"))
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(Color(.secondarySystemBackground))
|
|
||||||
}
|
|
||||||
|
|
||||||
if case .failed(let msg) = viewModel.imageUploadState {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
Text(msg)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
Spacer()
|
|
||||||
Button { viewModel.imageUploadState = .idle } label: {
|
|
||||||
Image(systemName: "xmark").font(.footnote)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(Color(.secondarySystemBackground))
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
FormattingToolbar(
|
|
||||||
imagePickerItem: $imagePickerItem,
|
|
||||||
isUploadingImage: {
|
|
||||||
if case .uploading = viewModel.imageUploadState { return true }
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
) { 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)
|
.navigationTitle(navigationTitle)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar { editorToolbar }
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
.alert(L("editor.close.unsaved.title"), isPresented: $showDiscardAlert) {
|
||||||
Button(L("editor.cancel")) {
|
Button(L("editor.close.unsaved.confirm"), role: .destructive) { dismiss() }
|
||||||
if viewModel.hasUnsavedChanges {
|
|
||||||
showDiscardAlert = true
|
|
||||||
} else {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Write / Preview toggle lives in the nav bar
|
|
||||||
ToolbarItem(placement: .principal) {
|
|
||||||
EditorTabToggle(activeTab: $viewModel.activeTab)
|
|
||||||
}
|
|
||||||
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) {}
|
Button(L("editor.discard.keepediting"), role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text(L("editor.discard.message"))
|
Text(L("editor.discard.message"))
|
||||||
}
|
}
|
||||||
// Handle image picked from photo library
|
|
||||||
.onChange(of: imagePickerItem) { _, newItem in
|
.onChange(of: imagePickerItem) { _, newItem in
|
||||||
guard let newItem else { return }
|
guard let newItem else { return }
|
||||||
Task {
|
Task {
|
||||||
@@ -209,7 +107,6 @@ struct PageEditorView: View {
|
|||||||
imagePickerItem = nil
|
imagePickerItem = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// When upload completes, insert markdown at cursor
|
|
||||||
.onChange(of: viewModel.pendingImageMarkdown) { _, markdown in
|
.onChange(of: viewModel.pendingImageMarkdown) { _, markdown in
|
||||||
guard let markdown else { return }
|
guard let markdown else { return }
|
||||||
viewModel.pendingImageMarkdown = nil
|
viewModel.pendingImageMarkdown = nil
|
||||||
@@ -221,6 +118,123 @@ struct PageEditorView: View {
|
|||||||
let insertion = "\n\(markdown)\n"
|
let insertion = "\n\(markdown)\n"
|
||||||
replace(in: tv, range: range, with: insertion, cursorOffset: insertion.count)
|
replace(in: tv, range: range, with: insertion, cursorOffset: insertion.count)
|
||||||
}
|
}
|
||||||
|
.task { await viewModel.loadAvailableTags() }
|
||||||
|
.sheet(isPresented: $showTagEditor) {
|
||||||
|
TagEditorSheet(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Editor content (extracted for type-checker)
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var editorContent: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Title field
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
TextField(L("editor.title.placeholder"), text: $viewModel.title)
|
||||||
|
.font(.system(size: 22, weight: .bold))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 14)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
Rectangle().fill(Color(.separator)).frame(height: 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag strip
|
||||||
|
TagStripView(
|
||||||
|
tags: viewModel.tags,
|
||||||
|
onRemove: { viewModel.removeTag($0) },
|
||||||
|
onAdd: { showTagEditor = true }
|
||||||
|
)
|
||||||
|
Rectangle().fill(Color(.separator)).frame(height: 0.5)
|
||||||
|
|
||||||
|
// Content area
|
||||||
|
if viewModel.activeTab == .write {
|
||||||
|
writeArea
|
||||||
|
} else {
|
||||||
|
MarkdownPreviewView(markdown: viewModel.markdownContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save error
|
||||||
|
if let error = viewModel.saveError {
|
||||||
|
ErrorBanner(error: error) { Task { await viewModel.save() } }
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var writeArea: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
MarkdownTextEditor(text: $viewModel.markdownContent) { tv in textView = tv }
|
||||||
|
|
||||||
|
if case .uploading = viewModel.imageUploadState {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
Text(L("editor.image.uploading")).font(.footnote).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .failed(let msg) = viewModel.imageUploadState {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill").foregroundStyle(.red)
|
||||||
|
Text(msg).font(.footnote).foregroundStyle(.red)
|
||||||
|
Spacer()
|
||||||
|
Button { viewModel.imageUploadState = .idle } label: {
|
||||||
|
Image(systemName: "xmark").font(.footnote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
FormattingToolbar(
|
||||||
|
imagePickerItem: $imagePickerItem,
|
||||||
|
isUploadingImage: {
|
||||||
|
if case .uploading = viewModel.imageUploadState { return true }
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
) { action in applyFormat(action) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ToolbarContentBuilder
|
||||||
|
private var editorToolbar: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(L("editor.close")) {
|
||||||
|
if viewModel.hasUnsavedChanges { showDiscardAlert = true } else { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
EditorTabToggle(activeTab: $viewModel.activeTab)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
ZStack {
|
||||||
|
if showSavedConfirmation {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
} else {
|
||||||
|
Button(L("editor.save")) {
|
||||||
|
Task {
|
||||||
|
await viewModel.save()
|
||||||
|
if viewModel.saveError == nil {
|
||||||
|
withAnimation(.spring(duration: 0.3)) { showSavedConfirmation = true }
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
withAnimation(.easeOut(duration: 0.3)) { showSavedConfirmation = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(viewModel.title.isEmpty || viewModel.markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving)
|
||||||
|
.overlay { if viewModel.isSaving { ProgressView().scaleEffect(0.7) } }
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: showSavedConfirmation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,34 +424,39 @@ struct FormattingToolbar: View {
|
|||||||
let onAction: (FormatAction) -> Void
|
let onAction: (FormatAction) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
VStack(spacing: 0) {
|
||||||
HStack(spacing: 6) {
|
Rectangle()
|
||||||
|
.fill(Color(.separator))
|
||||||
|
.frame(height: 0.5)
|
||||||
|
|
||||||
|
// Row 1: Headings + text formatting
|
||||||
|
HStack(spacing: 0) {
|
||||||
FormatButton("H1", action: .h1, onAction: onAction)
|
FormatButton("H1", action: .h1, onAction: onAction)
|
||||||
FormatButton("H2", action: .h2, onAction: onAction)
|
FormatButton("H2", action: .h2, onAction: onAction)
|
||||||
FormatButton("H3", action: .h3, onAction: onAction)
|
FormatButton("H3", action: .h3, onAction: onAction)
|
||||||
|
|
||||||
toolbarDivider
|
toolbarDivider
|
||||||
|
|
||||||
FormatButton(systemImage: "bold", action: .bold, onAction: onAction)
|
FormatButton(systemImage: "bold", action: .bold, onAction: onAction)
|
||||||
FormatButton(systemImage: "italic", action: .italic, onAction: onAction)
|
FormatButton(systemImage: "italic", action: .italic, onAction: onAction)
|
||||||
FormatButton(systemImage: "strikethrough", action: .strikethrough, onAction: onAction)
|
FormatButton(systemImage: "strikethrough", action: .strikethrough, onAction: onAction)
|
||||||
FormatButton(systemImage: "chevron.left.forwardslash.chevron.right", action: .inlineCode, onAction: onAction)
|
FormatButton(systemImage: "chevron.left.forwardslash.chevron.right", action: .inlineCode, onAction: onAction)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
toolbarDivider
|
Rectangle()
|
||||||
|
.fill(Color(.separator))
|
||||||
|
.frame(height: 0.5)
|
||||||
|
|
||||||
|
// Row 2: Lists + block elements + image
|
||||||
|
HStack(spacing: 0) {
|
||||||
FormatButton(systemImage: "list.bullet", action: .bulletList, onAction: onAction)
|
FormatButton(systemImage: "list.bullet", action: .bulletList, onAction: onAction)
|
||||||
FormatButton(systemImage: "list.number", action: .numberedList, onAction: onAction)
|
FormatButton(systemImage: "list.number", action: .numberedList, onAction: onAction)
|
||||||
FormatButton(systemImage: "text.quote", action: .blockquote, onAction: onAction)
|
FormatButton(systemImage: "text.quote", action: .blockquote, onAction: onAction)
|
||||||
|
|
||||||
toolbarDivider
|
toolbarDivider
|
||||||
|
|
||||||
FormatButton(systemImage: "link", action: .link, onAction: onAction)
|
FormatButton(systemImage: "link", action: .link, onAction: onAction)
|
||||||
FormatButton(systemImage: "curlybraces", action: .codeBlock, onAction: onAction)
|
FormatButton(systemImage: "curlybraces", action: .codeBlock, onAction: onAction)
|
||||||
FormatButton(systemImage: "minus", action: .horizontalRule, onAction: onAction)
|
FormatButton(systemImage: "minus", action: .horizontalRule, onAction: onAction)
|
||||||
|
|
||||||
toolbarDivider
|
toolbarDivider
|
||||||
|
// Image picker
|
||||||
// Image picker button
|
|
||||||
PhotosPicker(
|
PhotosPicker(
|
||||||
selection: $imagePickerItem,
|
selection: $imagePickerItem,
|
||||||
matching: .images,
|
matching: .images,
|
||||||
@@ -448,31 +467,24 @@ struct FormattingToolbar: View {
|
|||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.small)
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "photo")
|
Image(systemName: "photo")
|
||||||
.font(.system(size: 15, weight: .medium))
|
.font(.system(size: 14, weight: .regular))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 40, height: 36)
|
.frame(maxWidth: .infinity, minHeight: 36)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 9))
|
|
||||||
}
|
}
|
||||||
.disabled(isUploadingImage)
|
.disabled(isUploadingImage)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
}
|
||||||
.background(Color(.systemBackground))
|
.background(Color(.systemBackground))
|
||||||
.overlay(alignment: .top) {
|
|
||||||
Rectangle()
|
|
||||||
.fill(Color(.separator))
|
|
||||||
.frame(height: 0.5)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var toolbarDivider: some View {
|
private var toolbarDivider: some View {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color(.separator))
|
.fill(Color(.separator))
|
||||||
.frame(width: 0.5, height: 22)
|
.frame(width: 0.5)
|
||||||
.padding(.horizontal, 2)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,15 +514,14 @@ struct FormatButton: View {
|
|||||||
Group {
|
Group {
|
||||||
if let label {
|
if let label {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||||
} else if let systemImage {
|
} else if let systemImage {
|
||||||
Image(systemName: systemImage)
|
Image(systemName: systemImage)
|
||||||
.font(.system(size: 15, weight: .medium))
|
.font(.system(size: 14, weight: .regular))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 40, height: 36)
|
.frame(maxWidth: .infinity, minHeight: 36)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 9))
|
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
onAction(action)
|
onAction(action)
|
||||||
@@ -597,10 +608,204 @@ struct MarkdownPreviewView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Tag Strip (inline in editor)
|
||||||
|
|
||||||
|
struct TagStripView: View {
|
||||||
|
let tags: [TagDTO]
|
||||||
|
let onRemove: (TagDTO) -> Void
|
||||||
|
let onAdd: () -> Void
|
||||||
|
@Environment(\.accentTheme) private var theme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(tags) { tag in
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(tag.value.isEmpty ? tag.name : "\(tag.name): \(tag.value)")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(theme.accentColor)
|
||||||
|
Button {
|
||||||
|
onRemove(tag)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.foregroundStyle(theme.accentColor.opacity(0.7))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(theme.accentColor.opacity(0.12), in: Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tag button
|
||||||
|
Button(action: onAdd) {
|
||||||
|
Label(L("editor.tags.add"), systemImage: "tag")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color(.secondarySystemBackground), in: Capsule())
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tag Editor Sheet
|
||||||
|
|
||||||
|
struct TagEditorSheet: View {
|
||||||
|
/// Using the viewModel directly ensures live updates to availableTags and
|
||||||
|
/// that mutations to tags are always reflected back in the editor.
|
||||||
|
var viewModel: PageEditorViewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.accentTheme) private var theme
|
||||||
|
|
||||||
|
@State private var newTagName: String = ""
|
||||||
|
@State private var newTagValue: String = ""
|
||||||
|
@State private var searchText: String = ""
|
||||||
|
|
||||||
|
// Tags from the server not yet assigned to this page
|
||||||
|
private var unassignedTags: [TagDTO] {
|
||||||
|
let assignedIds = Set(viewModel.tags.map(\.id))
|
||||||
|
let all = viewModel.availableTags.filter { !assignedIds.contains($0.id) }
|
||||||
|
// Deduplicate by name+value, then filter by search
|
||||||
|
var seen = Set<String>()
|
||||||
|
let unique = all.filter { seen.insert($0.id).inserted }
|
||||||
|
guard !searchText.isEmpty else { return unique }
|
||||||
|
return unique.filter {
|
||||||
|
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.value.localizedCaseInsensitiveContains(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
// Currently assigned tags
|
||||||
|
if !viewModel.tags.isEmpty {
|
||||||
|
Section(L("editor.tags.current")) {
|
||||||
|
ForEach(viewModel.tags) { tag in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "tag.fill")
|
||||||
|
.foregroundStyle(theme.accentColor)
|
||||||
|
.font(.footnote)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(tag.name)
|
||||||
|
.font(.body)
|
||||||
|
if !tag.value.isEmpty {
|
||||||
|
Text(tag.value)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
viewModel.removeTag(tag)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.title3)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available tags from server — tap to assign
|
||||||
|
if !unassignedTags.isEmpty || viewModel.isLoadingTags {
|
||||||
|
Section(L("editor.tags.available")) {
|
||||||
|
if viewModel.isLoadingTags {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
Text(L("editor.tags.loading"))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ForEach(unassignedTags) { tag in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "tag")
|
||||||
|
.foregroundStyle(theme.accentColor)
|
||||||
|
.font(.footnote)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(tag.name)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
if !tag.value.isEmpty {
|
||||||
|
Text(tag.value)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "plus.circle")
|
||||||
|
.foregroundStyle(theme.accentColor)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
viewModel.addTag(name: tag.name, value: tag.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a brand-new tag
|
||||||
|
Section(L("editor.tags.new")) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "tag")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.footnote)
|
||||||
|
TextField(L("editor.tags.name"), text: $newTagName)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "textformat")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.footnote)
|
||||||
|
TextField(L("editor.tags.value"), text: $newTagValue)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
addTag(name: newTagName, value: newTagValue)
|
||||||
|
} label: {
|
||||||
|
Label(L("editor.tags.create"), systemImage: "plus.circle.fill")
|
||||||
|
.foregroundStyle(newTagName.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
? .secondary : theme.accentColor)
|
||||||
|
}
|
||||||
|
.disabled(newTagName.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(L("editor.tags.title"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: L("editor.tags.search"))
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(L("common.done")) { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addTag(name: String, value: String) {
|
||||||
|
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedName.isEmpty else { return }
|
||||||
|
viewModel.addTag(name: trimmedName, value: trimmedValue)
|
||||||
|
newTagName = ""
|
||||||
|
newTagValue = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview("New Page") {
|
#Preview("New Page") {
|
||||||
PageEditorView(mode: .create(bookId: 1))
|
NavigationStack { PageEditorView(mode: .create(bookId: 1)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview("Edit Page") {
|
#Preview("Edit Page") {
|
||||||
PageEditorView(mode: .edit(page: .mock))
|
NavigationStack { PageEditorView(mode: .edit(page: .mock)) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ struct BookDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showNewPage) {
|
.sheet(isPresented: $showNewPage) {
|
||||||
PageEditorView(mode: .create(bookId: book.id))
|
NavigationStack { PageEditorView(mode: .create(bookId: book.id)) }
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showNewChapter) {
|
.sheet(isPresented: $showNewChapter) {
|
||||||
NewChapterView(bookId: book.id)
|
NewChapterView(bookId: book.id)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
struct BooksInShelfView: View {
|
struct BooksInShelfView: View {
|
||||||
let shelf: ShelfDTO
|
let shelf: ShelfDTO
|
||||||
@State private var viewModel = LibraryViewModel()
|
@State private var viewModel = LibraryViewModel()
|
||||||
|
@State private var showNewBook = false
|
||||||
@Environment(\.accentTheme) private var theme
|
@Environment(\.accentTheme) private var theme
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@@ -65,6 +66,22 @@ struct BooksInShelfView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(shelf.name)
|
.navigationTitle(shelf.name)
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showNewBook = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.accessibilityLabel(L("shelf.newbook"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showNewBook) {
|
||||||
|
NewBookView(preselectedShelf: shelf)
|
||||||
|
}
|
||||||
|
.onChange(of: showNewBook) { _, isShowing in
|
||||||
|
if !isShowing { Task { await viewModel.loadBooksForShelf(shelfId: shelf.id) } }
|
||||||
|
}
|
||||||
.task { await viewModel.loadBooksForShelf(shelfId: shelf.id) }
|
.task { await viewModel.loadBooksForShelf(shelfId: shelf.id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct LibraryView: View {
|
struct LibraryView: View {
|
||||||
@State private var viewModel = LibraryViewModel()
|
@State private var viewModel = LibraryViewModel()
|
||||||
|
@State private var showNewShelf = false
|
||||||
@Environment(ConnectivityMonitor.self) private var connectivity
|
@Environment(ConnectivityMonitor.self) private var connectivity
|
||||||
@Environment(\.accentTheme) private var theme
|
@Environment(\.accentTheme) private var theme
|
||||||
|
|
||||||
@@ -49,6 +50,22 @@ struct LibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(L("library.title"))
|
.navigationTitle(L("library.title"))
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showNewShelf = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.accessibilityLabel(L("library.newshelf"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showNewShelf) {
|
||||||
|
NewShelfView()
|
||||||
|
}
|
||||||
|
.onChange(of: showNewShelf) { _, isShowing in
|
||||||
|
if !isShowing { Task { await viewModel.loadShelves() } }
|
||||||
|
}
|
||||||
.navigationDestination(for: ShelfDTO.self) { shelf in
|
.navigationDestination(for: ShelfDTO.self) { shelf in
|
||||||
BooksInShelfView(shelf: shelf)
|
BooksInShelfView(shelf: shelf)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,205 +1,9 @@
|
|||||||
import SwiftUI
|
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
|
// MARK: - New Book View
|
||||||
|
|
||||||
struct NewBookView: View {
|
struct NewBookView: View {
|
||||||
|
var preselectedShelf: ShelfDTO? = nil
|
||||||
@State private var name = ""
|
@State private var name = ""
|
||||||
@State private var bookDescription = ""
|
@State private var bookDescription = ""
|
||||||
@State private var shelves: [ShelfDTO] = []
|
@State private var shelves: [ShelfDTO] = []
|
||||||
@@ -266,6 +70,9 @@ struct NewBookView: View {
|
|||||||
.task {
|
.task {
|
||||||
shelves = (try? await BookStackAPI.shared.fetchShelves()) ?? []
|
shelves = (try? await BookStackAPI.shared.fetchShelves()) ?? []
|
||||||
isLoadingShelves = false
|
isLoadingShelves = false
|
||||||
|
if let preselected = preselectedShelf {
|
||||||
|
selectedShelf = shelves.first { $0.id == preselected.id } ?? preselected
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,6 +152,10 @@ struct NewShelfView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview("New Shelf") {
|
||||||
NewContentView()
|
NewShelfView()
|
||||||
}
|
}
|
||||||
|
#Preview("New Book") {
|
||||||
|
NewBookView()
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,10 +90,12 @@ struct PageReaderView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showEditor) {
|
.sheet(isPresented: $showEditor) {
|
||||||
|
NavigationStack {
|
||||||
if let fullPage {
|
if let fullPage {
|
||||||
PageEditorView(mode: .edit(page: fullPage))
|
PageEditorView(mode: .edit(page: fullPage))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.task(id: page.id) {
|
.task(id: page.id) {
|
||||||
await loadFullPage()
|
await loadFullPage()
|
||||||
await loadComments()
|
await loadComments()
|
||||||
|
|||||||
@@ -15,8 +15,43 @@ struct SearchView: View {
|
|||||||
@State private var navigationActive = false
|
@State private var navigationActive = false
|
||||||
@State private var loadError: BookStackError? = nil
|
@State private var loadError: BookStackError? = nil
|
||||||
|
|
||||||
|
@FocusState private var searchFocused: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Persistent search bar
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField(L("search.prompt"), text: $viewModel.query)
|
||||||
|
.focused($searchFocused)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.submitLabel(.search)
|
||||||
|
.onSubmit { viewModel.onQueryChanged() }
|
||||||
|
.onChange(of: viewModel.query) { viewModel.onQueryChanged() }
|
||||||
|
if !viewModel.query.isEmpty {
|
||||||
|
Button {
|
||||||
|
viewModel.query = ""
|
||||||
|
viewModel.results = []
|
||||||
|
searchFocused = false
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
if viewModel.query.isEmpty {
|
if viewModel.query.isEmpty {
|
||||||
recentSearchesView
|
recentSearchesView
|
||||||
@@ -28,16 +63,15 @@ struct SearchView: View {
|
|||||||
resultsList
|
resultsList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.navigationTitle(L("search.title"))
|
.navigationTitle(L("search.title"))
|
||||||
.searchable(text: $viewModel.query, prompt: L("search.prompt"))
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onChange(of: viewModel.query) { viewModel.onQueryChanged() }
|
.task { await viewModel.loadAvailableTags() }
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if !SearchResultDTO.ContentType.allCases.isEmpty {
|
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
filterMenu
|
filterMenu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.navigationDestination(isPresented: $navigationActive) {
|
.navigationDestination(isPresented: $navigationActive) {
|
||||||
switch destination {
|
switch destination {
|
||||||
case .page(let page): PageReaderView(page: page)
|
case .page(let page): PageReaderView(page: page)
|
||||||
@@ -172,37 +206,58 @@ struct SearchView: View {
|
|||||||
|
|
||||||
// MARK: - Filter Menu
|
// MARK: - Filter Menu
|
||||||
|
|
||||||
|
private var hasActiveFilter: Bool {
|
||||||
|
viewModel.selectedTypeFilter != nil || viewModel.selectedTagFilter != nil
|
||||||
|
}
|
||||||
|
|
||||||
private var filterMenu: some View {
|
private var filterMenu: some View {
|
||||||
Menu {
|
Menu {
|
||||||
|
// Type filter
|
||||||
|
Section(L("search.filter.type")) {
|
||||||
Button {
|
Button {
|
||||||
viewModel.selectedTypeFilter = nil
|
viewModel.selectedTypeFilter = nil
|
||||||
viewModel.onFilterChanged()
|
viewModel.onFilterChanged()
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
Label(L("search.filter.all"),
|
||||||
Text(L("search.filter.all"))
|
systemImage: viewModel.selectedTypeFilter == nil ? "checkmark" : "square")
|
||||||
if viewModel.selectedTypeFilter == nil {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
Divider()
|
|
||||||
ForEach(SearchResultDTO.ContentType.allCases, id: \.self) { type in
|
ForEach(SearchResultDTO.ContentType.allCases, id: \.self) { type in
|
||||||
Button {
|
Button {
|
||||||
viewModel.selectedTypeFilter = type
|
viewModel.selectedTypeFilter = (viewModel.selectedTypeFilter == type) ? nil : type
|
||||||
viewModel.onFilterChanged()
|
viewModel.onFilterChanged()
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
Label(type.displayName,
|
||||||
Label(type.displayName, systemImage: type.systemImage)
|
systemImage: viewModel.selectedTypeFilter == type ? "checkmark" : type.systemImage)
|
||||||
if viewModel.selectedTypeFilter == type {
|
}
|
||||||
Image(systemName: "checkmark")
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag filter
|
||||||
|
if !viewModel.availableTags.isEmpty {
|
||||||
|
Section(L("search.filter.tag")) {
|
||||||
|
if viewModel.selectedTagFilter != nil {
|
||||||
|
Button {
|
||||||
|
viewModel.selectedTagFilter = nil
|
||||||
|
viewModel.onFilterChanged()
|
||||||
|
} label: {
|
||||||
|
Label(L("search.filter.tag.clear"), systemImage: "xmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let uniqueNames = Array(Set(viewModel.availableTags.map(\.name))).sorted()
|
||||||
|
ForEach(uniqueNames.prefix(15), id: \.self) { name in
|
||||||
|
Button {
|
||||||
|
viewModel.selectedTagFilter = (viewModel.selectedTagFilter == name) ? nil : name
|
||||||
|
viewModel.onFilterChanged()
|
||||||
|
} label: {
|
||||||
|
Label(name, systemImage: viewModel.selectedTagFilter == name ? "checkmark" : "tag")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: viewModel.selectedTypeFilter == nil
|
Image(systemName: hasActiveFilter
|
||||||
? "line.3.horizontal.decrease.circle"
|
? "line.3.horizontal.decrease.circle.fill"
|
||||||
: "line.3.horizontal.decrease.circle.fill")
|
: "line.3.horizontal.decrease.circle")
|
||||||
}
|
}
|
||||||
.accessibilityLabel(L("search.filter"))
|
.accessibilityLabel(L("search.filter"))
|
||||||
}
|
}
|
||||||
@@ -242,6 +297,23 @@ struct SearchResultRow: View {
|
|||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.padding(.leading, 26)
|
.padding(.leading, 26)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !result.tags.isEmpty {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
ForEach(result.tags) { tag in
|
||||||
|
Text(tag.value.isEmpty ? tag.name : "\(tag.name): \(tag.value)")
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color(.tertiarySystemBackground), in: Capsule())
|
||||||
|
.overlay(Capsule().strokeBorder(Color(.separator), lineWidth: 0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, 26)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.accessibilityLabel("\(result.type.displayName): \(result.name)")
|
.accessibilityLabel("\(result.type.displayName): \(result.name)")
|
||||||
|
|||||||
@@ -54,11 +54,13 @@
|
|||||||
"library.refresh" = "Aktualisieren";
|
"library.refresh" = "Aktualisieren";
|
||||||
"library.shelves" = "Regale";
|
"library.shelves" = "Regale";
|
||||||
"library.updated" = "Aktualisiert %@";
|
"library.updated" = "Aktualisiert %@";
|
||||||
|
"library.newshelf" = "Neues Regal";
|
||||||
|
|
||||||
// MARK: - Shelf
|
// MARK: - Shelf
|
||||||
"shelf.loading" = "Bücher werden geladen…";
|
"shelf.loading" = "Bücher werden geladen…";
|
||||||
"shelf.empty.title" = "Keine Bücher";
|
"shelf.empty.title" = "Keine Bücher";
|
||||||
"shelf.empty.message" = "Dieses Regal enthält noch keine Bücher.";
|
"shelf.empty.message" = "Dieses Regal enthält noch keine Bücher.";
|
||||||
|
"shelf.newbook" = "Neues Buch";
|
||||||
|
|
||||||
// MARK: - Book
|
// MARK: - Book
|
||||||
"book.loading" = "Inhalte werden geladen…";
|
"book.loading" = "Inhalte werden geladen…";
|
||||||
@@ -97,11 +99,10 @@
|
|||||||
"editor.tab.write" = "Schreiben";
|
"editor.tab.write" = "Schreiben";
|
||||||
"editor.tab.preview" = "Vorschau";
|
"editor.tab.preview" = "Vorschau";
|
||||||
"editor.save" = "Speichern";
|
"editor.save" = "Speichern";
|
||||||
"editor.cancel" = "Abbrechen";
|
"editor.close" = "Schließen";
|
||||||
"editor.discard.title" = "Änderungen verwerfen?";
|
|
||||||
"editor.discard.message" = "Deine Änderungen gehen verloren.";
|
|
||||||
"editor.discard.confirm" = "Verwerfen";
|
|
||||||
"editor.discard.keepediting" = "Weiter bearbeiten";
|
"editor.discard.keepediting" = "Weiter bearbeiten";
|
||||||
|
"editor.close.unsaved.title" = "Schließen ohne zu speichern?";
|
||||||
|
"editor.close.unsaved.confirm" = "Schließen";
|
||||||
"editor.image.uploading" = "Bild wird hochgeladen…";
|
"editor.image.uploading" = "Bild wird hochgeladen…";
|
||||||
|
|
||||||
// MARK: - Search
|
// MARK: - Search
|
||||||
@@ -197,6 +198,23 @@
|
|||||||
"settings.log.viewer.title" = "App-Protokoll";
|
"settings.log.viewer.title" = "App-Protokoll";
|
||||||
"settings.log.entries" = "%d Einträge";
|
"settings.log.entries" = "%d Einträge";
|
||||||
|
|
||||||
|
// MARK: - Tags
|
||||||
|
"editor.tags.title" = "Tags";
|
||||||
|
"editor.tags.add" = "Tag hinzufügen";
|
||||||
|
"editor.tags.create" = "Neuen Tag erstellen";
|
||||||
|
"editor.tags.name" = "Tag-Name";
|
||||||
|
"editor.tags.value" = "Wert (optional)";
|
||||||
|
"editor.tags.current" = "Zugewiesene Tags";
|
||||||
|
"editor.tags.available" = "Verfügbare Tags";
|
||||||
|
"editor.tags.loading" = "Tags werden geladen…";
|
||||||
|
"editor.tags.new" = "Tag erstellen";
|
||||||
|
"editor.tags.search" = "Tags suchen…";
|
||||||
|
"editor.tags.suggestions" = "Vorschläge";
|
||||||
|
"search.filter.type" = "Inhaltstyp";
|
||||||
|
"search.filter.tag" = "Tag";
|
||||||
|
"search.filter.tag.clear" = "Tag-Filter entfernen";
|
||||||
|
|
||||||
// MARK: - Common
|
// MARK: - Common
|
||||||
"common.ok" = "OK";
|
"common.ok" = "OK";
|
||||||
"common.error" = "Unbekannter Fehler";
|
"common.error" = "Unbekannter Fehler";
|
||||||
|
"common.done" = "Fertig";
|
||||||
|
|||||||
@@ -54,11 +54,13 @@
|
|||||||
"library.refresh" = "Refresh";
|
"library.refresh" = "Refresh";
|
||||||
"library.shelves" = "Shelves";
|
"library.shelves" = "Shelves";
|
||||||
"library.updated" = "Updated %@";
|
"library.updated" = "Updated %@";
|
||||||
|
"library.newshelf" = "New Shelf";
|
||||||
|
|
||||||
// MARK: - Shelf
|
// MARK: - Shelf
|
||||||
"shelf.loading" = "Loading books…";
|
"shelf.loading" = "Loading books…";
|
||||||
"shelf.empty.title" = "No Books";
|
"shelf.empty.title" = "No Books";
|
||||||
"shelf.empty.message" = "This shelf has no books yet.";
|
"shelf.empty.message" = "This shelf has no books yet.";
|
||||||
|
"shelf.newbook" = "New Book";
|
||||||
|
|
||||||
// MARK: - Book
|
// MARK: - Book
|
||||||
"book.loading" = "Loading content…";
|
"book.loading" = "Loading content…";
|
||||||
@@ -97,11 +99,10 @@
|
|||||||
"editor.tab.write" = "Write";
|
"editor.tab.write" = "Write";
|
||||||
"editor.tab.preview" = "Preview";
|
"editor.tab.preview" = "Preview";
|
||||||
"editor.save" = "Save";
|
"editor.save" = "Save";
|
||||||
"editor.cancel" = "Cancel";
|
"editor.close" = "Close";
|
||||||
"editor.discard.title" = "Discard Changes?";
|
|
||||||
"editor.discard.message" = "Your changes will be lost.";
|
|
||||||
"editor.discard.confirm" = "Discard";
|
|
||||||
"editor.discard.keepediting" = "Keep Editing";
|
"editor.discard.keepediting" = "Keep Editing";
|
||||||
|
"editor.close.unsaved.title" = "Close without saving?";
|
||||||
|
"editor.close.unsaved.confirm" = "Close";
|
||||||
"editor.image.uploading" = "Uploading image…";
|
"editor.image.uploading" = "Uploading image…";
|
||||||
|
|
||||||
// MARK: - Search
|
// MARK: - Search
|
||||||
@@ -197,6 +198,23 @@
|
|||||||
"settings.log.viewer.title" = "App Log";
|
"settings.log.viewer.title" = "App Log";
|
||||||
"settings.log.entries" = "%d entries";
|
"settings.log.entries" = "%d entries";
|
||||||
|
|
||||||
|
// MARK: - Tags
|
||||||
|
"editor.tags.title" = "Tags";
|
||||||
|
"editor.tags.add" = "Add Tag";
|
||||||
|
"editor.tags.create" = "Create New Tag";
|
||||||
|
"editor.tags.name" = "Tag name";
|
||||||
|
"editor.tags.value" = "Value (optional)";
|
||||||
|
"editor.tags.current" = "Assigned Tags";
|
||||||
|
"editor.tags.available" = "Available Tags";
|
||||||
|
"editor.tags.loading" = "Loading tags…";
|
||||||
|
"editor.tags.new" = "Create Tag";
|
||||||
|
"editor.tags.search" = "Search tags…";
|
||||||
|
"editor.tags.suggestions" = "Suggestions";
|
||||||
|
"search.filter.type" = "Content Type";
|
||||||
|
"search.filter.tag" = "Tag";
|
||||||
|
"search.filter.tag.clear" = "Clear Tag Filter";
|
||||||
|
|
||||||
// MARK: - Common
|
// MARK: - Common
|
||||||
"common.ok" = "OK";
|
"common.ok" = "OK";
|
||||||
"common.error" = "Unknown error";
|
"common.error" = "Unknown error";
|
||||||
|
"common.done" = "Done";
|
||||||
|
|||||||
@@ -54,11 +54,13 @@
|
|||||||
"library.refresh" = "Actualizar";
|
"library.refresh" = "Actualizar";
|
||||||
"library.shelves" = "Estantes";
|
"library.shelves" = "Estantes";
|
||||||
"library.updated" = "Actualizado %@";
|
"library.updated" = "Actualizado %@";
|
||||||
|
"library.newshelf" = "Nuevo estante";
|
||||||
|
|
||||||
// MARK: - Shelf
|
// MARK: - Shelf
|
||||||
"shelf.loading" = "Cargando libros…";
|
"shelf.loading" = "Cargando libros…";
|
||||||
"shelf.empty.title" = "Sin libros";
|
"shelf.empty.title" = "Sin libros";
|
||||||
"shelf.empty.message" = "Este estante aún no tiene libros.";
|
"shelf.empty.message" = "Este estante aún no tiene libros.";
|
||||||
|
"shelf.newbook" = "Nuevo libro";
|
||||||
|
|
||||||
// MARK: - Book
|
// MARK: - Book
|
||||||
"book.loading" = "Cargando contenido…";
|
"book.loading" = "Cargando contenido…";
|
||||||
@@ -97,11 +99,10 @@
|
|||||||
"editor.tab.write" = "Escribir";
|
"editor.tab.write" = "Escribir";
|
||||||
"editor.tab.preview" = "Vista previa";
|
"editor.tab.preview" = "Vista previa";
|
||||||
"editor.save" = "Guardar";
|
"editor.save" = "Guardar";
|
||||||
"editor.cancel" = "Cancelar";
|
"editor.close" = "Cerrar";
|
||||||
"editor.discard.title" = "¿Descartar cambios?";
|
|
||||||
"editor.discard.message" = "Se perderán todos tus cambios.";
|
|
||||||
"editor.discard.confirm" = "Descartar";
|
|
||||||
"editor.discard.keepediting" = "Seguir editando";
|
"editor.discard.keepediting" = "Seguir editando";
|
||||||
|
"editor.close.unsaved.title" = "¿Cerrar sin guardar?";
|
||||||
|
"editor.close.unsaved.confirm" = "Cerrar";
|
||||||
"editor.image.uploading" = "Subiendo imagen…";
|
"editor.image.uploading" = "Subiendo imagen…";
|
||||||
|
|
||||||
// MARK: - Search
|
// MARK: - Search
|
||||||
@@ -197,6 +198,23 @@
|
|||||||
"settings.log.viewer.title" = "Registro de la app";
|
"settings.log.viewer.title" = "Registro de la app";
|
||||||
"settings.log.entries" = "%d entradas";
|
"settings.log.entries" = "%d entradas";
|
||||||
|
|
||||||
|
// MARK: - Tags
|
||||||
|
"editor.tags.title" = "Etiquetas";
|
||||||
|
"editor.tags.add" = "Añadir etiqueta";
|
||||||
|
"editor.tags.create" = "Crear nueva etiqueta";
|
||||||
|
"editor.tags.name" = "Nombre de etiqueta";
|
||||||
|
"editor.tags.value" = "Valor (opcional)";
|
||||||
|
"editor.tags.current" = "Etiquetas asignadas";
|
||||||
|
"editor.tags.available" = "Etiquetas disponibles";
|
||||||
|
"editor.tags.loading" = "Cargando etiquetas…";
|
||||||
|
"editor.tags.new" = "Crear etiqueta";
|
||||||
|
"editor.tags.search" = "Buscar etiquetas…";
|
||||||
|
"editor.tags.suggestions" = "Sugerencias";
|
||||||
|
"search.filter.type" = "Tipo de contenido";
|
||||||
|
"search.filter.tag" = "Etiqueta";
|
||||||
|
"search.filter.tag.clear" = "Eliminar filtro de etiqueta";
|
||||||
|
|
||||||
// MARK: - Common
|
// MARK: - Common
|
||||||
"common.ok" = "Aceptar";
|
"common.ok" = "Aceptar";
|
||||||
"common.error" = "Error desconocido";
|
"common.error" = "Error desconocido";
|
||||||
|
"common.done" = "Listo";
|
||||||
|
|||||||
Reference in New Issue
Block a user