diff --git a/bookstax/MockData.swift b/bookstax/MockData.swift
index e4ef26c..27ebc2c 100644
--- a/bookstax/MockData.swift
+++ b/bookstax/MockData.swift
@@ -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",
priority: 1,
draftStatus: false,
+ tags: [TagDTO(name: "status", value: "draft", order: 0)],
createdAt: Date(),
updatedAt: Date()
)
static let mockList: [PageDTO] = [
mock,
- PageDTO(id: 2, bookId: 1, chapterId: 1, name: "Configuration", slug: "configuration", html: "
Configuration
Configure your environment.
", markdown: "# Configuration\n\nConfigure your environment.", priority: 2, draftStatus: false, createdAt: Date(), updatedAt: Date()),
- PageDTO(id: 3, bookId: 1, chapterId: nil, name: "Deployment", slug: "deployment", html: "Deployment
Deploy to production.
", markdown: "# Deployment\n\nDeploy to production.", priority: 3, draftStatus: false, createdAt: Date(), updatedAt: Date())
+ PageDTO(id: 2, bookId: 1, chapterId: 1, name: "Configuration", slug: "configuration", html: "Configuration
Configure your environment.
", markdown: "# Configuration\n\nConfigure your environment.", priority: 2, draftStatus: false, tags: [], createdAt: Date(), updatedAt: Date()),
+ PageDTO(id: 3, bookId: 1, chapterId: nil, name: "Deployment", slug: "deployment", html: "Deployment
Deploy to production.
", markdown: "# Deployment\n\nDeploy to production.", priority: 3, draftStatus: false, tags: [], createdAt: Date(), updatedAt: Date())
]
}
@@ -80,14 +81,15 @@ extension SearchResultDTO {
slug: "installation",
type: .page,
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] = [
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)
+ 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, tags: []),
+ SearchResultDTO(id: 4, name: "Engineering Docs", slug: "engineering-docs", type: .shelf, url: "/shelves/engineering-docs", preview: nil, tags: [])
]
}
diff --git a/bookstax/Models/DTOs.swift b/bookstax/Models/DTOs.swift
index 6be3fdd..5d33c60 100644
--- a/bookstax/Models/DTOs.swift
+++ b/bookstax/Models/DTOs.swift
@@ -10,6 +10,21 @@ nonisolated struct CoverDTO: Codable, Sendable, Hashable {
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: Codable, Sendable {
let data: [T]
let total: Int
@@ -87,17 +102,43 @@ nonisolated struct PageDTO: Codable, Sendable, Identifiable, Hashable {
let markdown: String?
let priority: Int
let draftStatus: Bool
+ let tags: [TagDTO]
let createdAt: Date
let updatedAt: Date
enum CodingKeys: String, CodingKey {
- case id, name, slug, html, markdown, priority
+ case id, name, slug, html, markdown, priority, tags
case bookId = "book_id"
case chapterId = "chapter_id"
case draftStatus = "draft"
case createdAt = "created_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
@@ -109,6 +150,23 @@ nonisolated struct SearchResultDTO: Codable, Sendable, Identifiable, Hashable {
let type: ContentType
let url: 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 {
case page, book, chapter, shelf
@@ -138,6 +196,13 @@ nonisolated struct SearchResponseDTO: Codable, Sendable {
let total: Int
}
+// MARK: - Tag List (from /api/tags)
+
+nonisolated struct TagListResponseDTO: Codable, Sendable {
+ let data: [TagDTO]
+ let total: Int
+}
+
// MARK: - Comment
nonisolated struct CommentDTO: Codable, Sendable, Identifiable, Hashable {
diff --git a/bookstax/Services/BookStackAPI.swift b/bookstax/Services/BookStackAPI.swift
index 579567b..f1fa623 100644
--- a/bookstax/Services/BookStackAPI.swift
+++ b/bookstax/Services/BookStackAPI.swift
@@ -254,43 +254,64 @@ actor BookStackAPI {
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 {
let bookId: Int
let chapterId: Int?
let name: String
let markdown: String
+ let tags: [TagBody]
enum CodingKeys: String, CodingKey {
case bookId = "book_id"
case chapterId = "chapter_id"
- case name, markdown
+ case name, markdown, tags
}
}
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 {
let name: String
let markdown: String
+ let tags: [TagBody]
}
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 {
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
- 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)
+ func search(query: String, type: SearchResultDTO.ContentType? = nil, tag: String? = nil) async throws -> SearchResponseDTO {
+ var q = query
+ if let type { q += " [type:\(type.rawValue)]" }
+ if let tag { q += " [tag:\(tag)]" }
+ let encoded = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? q
+ return try await request(endpoint: "search?query=\(encoded)")
}
// MARK: - Comments
diff --git a/bookstax/ViewModels/PageEditorViewModel.swift b/bookstax/ViewModels/PageEditorViewModel.swift
index e78bc28..2eb1256 100644
--- a/bookstax/ViewModels/PageEditorViewModel.swift
+++ b/bookstax/ViewModels/PageEditorViewModel.swift
@@ -19,7 +19,7 @@ final class PageEditorViewModel {
case failed(String)
}
- let mode: Mode
+ var mode: Mode
var title: String = ""
var markdownContent: String = ""
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
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 {
- switch mode {
- case .create:
- return !title.isEmpty || !markdownContent.isEmpty
- case .edit(let page):
- return title != page.name || markdownContent != (page.markdown ?? "")
- }
+ title != lastSavedTitle
+ || markdownContent != lastSavedMarkdown
+ || tags != lastSavedTags
}
init(mode: Mode) {
@@ -46,7 +53,36 @@ final class PageEditorViewModel {
if case .edit(let page) = mode {
title = page.name
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
@@ -60,22 +96,31 @@ final class PageEditorViewModel {
switch mode {
case .create(let bookId, let chapterId):
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,
chapterId: chapterId,
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):
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
+ markdown: markdownContent,
+ tags: tags
)
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 {
AppLog(.error, "Save failed for '\(title)': \(e.localizedDescription)", category: "Editor")
saveError = e
diff --git a/bookstax/ViewModels/SearchViewModel.swift b/bookstax/ViewModels/SearchViewModel.swift
index 35b9efd..7bfb635 100644
--- a/bookstax/ViewModels/SearchViewModel.swift
+++ b/bookstax/ViewModels/SearchViewModel.swift
@@ -9,6 +9,9 @@ final class SearchViewModel {
var isSearching: Bool = false
var error: BookStackError? = nil
var selectedTypeFilter: SearchResultDTO.ContentType? = nil
+ var selectedTagFilter: String? = nil
+ var availableTags: [TagDTO] = []
+ var isLoadingTags: Bool = false
var recentSearches: [String] {
get { UserDefaults.standard.stringArray(forKey: "recentSearches") ?? [] }
@@ -57,15 +60,31 @@ final class SearchViewModel {
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
private func performSearch() async {
isSearching = true
error = nil
- let filter = selectedTypeFilter.map { " [type:\($0.rawValue)]" } ?? ""
- AppLog(.info, "Search: \"\(query)\"\(filter)", category: "Search")
+ let typeLabel = selectedTypeFilter.map { " [type:\($0.rawValue)]" } ?? ""
+ let tagLabel = selectedTagFilter.map { " [tag:\($0)]" } ?? ""
+ AppLog(.info, "Search: \"\(query)\"\(typeLabel)\(tagLabel)", category: "Search")
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
AppLog(.info, "Search returned \(results.count) result(s) for \"\(query)\"", category: "Search")
addToRecent(query)
diff --git a/bookstax/Views/Editor/PageEditorView.swift b/bookstax/Views/Editor/PageEditorView.swift
index fa9afe6..2f55ee9 100644
--- a/bookstax/Views/Editor/PageEditorView.swift
+++ b/bookstax/Views/Editor/PageEditorView.swift
@@ -63,131 +63,29 @@ struct MarkdownTextEditor: UIViewRepresentable {
struct PageEditorView: View {
@State private var viewModel: PageEditorViewModel
@Environment(\.dismiss) private var dismiss
+ @Environment(\.accentTheme) private var theme
@State private var showDiscardAlert = false
+ @State private var showSavedConfirmation = false
/// Reference to the underlying UITextView for formatting operations
@State private var textView: UITextView? = nil
@State private var imagePickerItem: PhotosPickerItem? = nil
+ @State private var showTagEditor = false
init(mode: PageEditorViewModel.Mode) {
_viewModel = State(initialValue: PageEditorViewModel(mode: mode))
}
var body: some View {
- NavigationStack {
- 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()
- }
- }
+ editorContent
.navigationTitle(navigationTitle)
.navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button(L("editor.cancel")) {
- 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() }
+ .toolbar { editorToolbar }
+ .alert(L("editor.close.unsaved.title"), isPresented: $showDiscardAlert) {
+ Button(L("editor.close.unsaved.confirm"), role: .destructive) { dismiss() }
Button(L("editor.discard.keepediting"), role: .cancel) {}
} message: {
Text(L("editor.discard.message"))
}
- // Handle image picked from photo library
.onChange(of: imagePickerItem) { _, newItem in
guard let newItem else { return }
Task {
@@ -209,7 +107,6 @@ struct PageEditorView: View {
imagePickerItem = nil
}
}
- // When upload completes, insert markdown at cursor
.onChange(of: viewModel.pendingImageMarkdown) { _, markdown in
guard let markdown else { return }
viewModel.pendingImageMarkdown = nil
@@ -221,6 +118,123 @@ struct PageEditorView: View {
let insertion = "\n\(markdown)\n"
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
var body: some View {
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 6) {
+ VStack(spacing: 0) {
+ Rectangle()
+ .fill(Color(.separator))
+ .frame(height: 0.5)
+
+ // Row 1: Headings + text formatting
+ HStack(spacing: 0) {
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)
+ }
+ .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.number", action: .numberedList, onAction: onAction)
FormatButton(systemImage: "text.quote", action: .blockquote, onAction: onAction)
-
toolbarDivider
-
FormatButton(systemImage: "link", action: .link, onAction: onAction)
FormatButton(systemImage: "curlybraces", action: .codeBlock, onAction: onAction)
FormatButton(systemImage: "minus", action: .horizontalRule, onAction: onAction)
-
toolbarDivider
-
- // Image picker button
+ // Image picker
PhotosPicker(
selection: $imagePickerItem,
matching: .images,
@@ -448,31 +467,24 @@ struct FormattingToolbar: View {
ProgressView().controlSize(.small)
} else {
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)
- .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 9))
}
.disabled(isUploadingImage)
}
- .padding(.horizontal, 12)
- .padding(.vertical, 8)
+ .frame(maxWidth: .infinity)
}
.background(Color(.systemBackground))
- .overlay(alignment: .top) {
- Rectangle()
- .fill(Color(.separator))
- .frame(height: 0.5)
- }
}
private var toolbarDivider: some View {
Rectangle()
.fill(Color(.separator))
- .frame(width: 0.5, height: 22)
- .padding(.horizontal, 2)
+ .frame(width: 0.5)
+ .padding(.vertical, 8)
}
}
@@ -502,15 +514,14 @@ struct FormatButton: View {
Group {
if let label {
Text(label)
- .font(.system(size: 13, weight: .semibold, design: .rounded))
+ .font(.system(size: 12, weight: .medium, design: .rounded))
} else if let 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)
- .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 9))
.contentShape(Rectangle())
.onTapGesture {
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()
+ 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") {
- PageEditorView(mode: .create(bookId: 1))
+ NavigationStack { PageEditorView(mode: .create(bookId: 1)) }
}
#Preview("Edit Page") {
- PageEditorView(mode: .edit(page: .mock))
+ NavigationStack { PageEditorView(mode: .edit(page: .mock)) }
}
diff --git a/bookstax/Views/Library/BookDetailView.swift b/bookstax/Views/Library/BookDetailView.swift
index 202052a..e6c5bdb 100644
--- a/bookstax/Views/Library/BookDetailView.swift
+++ b/bookstax/Views/Library/BookDetailView.swift
@@ -143,7 +143,7 @@ struct BookDetailView: View {
}
}
.sheet(isPresented: $showNewPage) {
- PageEditorView(mode: .create(bookId: book.id))
+ NavigationStack { PageEditorView(mode: .create(bookId: book.id)) }
}
.sheet(isPresented: $showNewChapter) {
NewChapterView(bookId: book.id)
diff --git a/bookstax/Views/Library/BooksInShelfView.swift b/bookstax/Views/Library/BooksInShelfView.swift
index 898c02d..c4edf3d 100644
--- a/bookstax/Views/Library/BooksInShelfView.swift
+++ b/bookstax/Views/Library/BooksInShelfView.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct BooksInShelfView: View {
let shelf: ShelfDTO
@State private var viewModel = LibraryViewModel()
+ @State private var showNewBook = false
@Environment(\.accentTheme) private var theme
@Environment(\.dismiss) private var dismiss
@@ -65,6 +66,22 @@ struct BooksInShelfView: View {
}
.navigationTitle(shelf.name)
.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) }
}
}
diff --git a/bookstax/Views/Library/LibraryView.swift b/bookstax/Views/Library/LibraryView.swift
index 678e287..25a128c 100644
--- a/bookstax/Views/Library/LibraryView.swift
+++ b/bookstax/Views/Library/LibraryView.swift
@@ -2,6 +2,7 @@ import SwiftUI
struct LibraryView: View {
@State private var viewModel = LibraryViewModel()
+ @State private var showNewShelf = false
@Environment(ConnectivityMonitor.self) private var connectivity
@Environment(\.accentTheme) private var theme
@@ -49,6 +50,22 @@ struct LibraryView: View {
}
}
.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
BooksInShelfView(shelf: shelf)
}
diff --git a/bookstax/Views/NewContent/NewContentView.swift b/bookstax/Views/NewContent/NewContentView.swift
index 961cd4d..6e30232 100644
--- a/bookstax/Views/NewContent/NewContentView.swift
+++ b/bookstax/Views/NewContent/NewContentView.swift
@@ -1,205 +1,9 @@
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 {
+ var preselectedShelf: ShelfDTO? = nil
@State private var name = ""
@State private var bookDescription = ""
@State private var shelves: [ShelfDTO] = []
@@ -266,6 +70,9 @@ struct NewBookView: View {
.task {
shelves = (try? await BookStackAPI.shared.fetchShelves()) ?? []
isLoadingShelves = false
+ if let preselected = preselectedShelf {
+ selectedShelf = shelves.first { $0.id == preselected.id } ?? preselected
+ }
}
}
}
@@ -345,6 +152,10 @@ struct NewShelfView: View {
}
}
-#Preview {
- NewContentView()
+#Preview("New Shelf") {
+ NewShelfView()
}
+#Preview("New Book") {
+ NewBookView()
+}
+
diff --git a/bookstax/Views/Reader/PageReaderView.swift b/bookstax/Views/Reader/PageReaderView.swift
index 43310d6..90bb883 100644
--- a/bookstax/Views/Reader/PageReaderView.swift
+++ b/bookstax/Views/Reader/PageReaderView.swift
@@ -90,8 +90,10 @@ struct PageReaderView: View {
}
}
.sheet(isPresented: $showEditor) {
- if let fullPage {
- PageEditorView(mode: .edit(page: fullPage))
+ NavigationStack {
+ if let fullPage {
+ PageEditorView(mode: .edit(page: fullPage))
+ }
}
}
.task(id: page.id) {
diff --git a/bookstax/Views/Search/SearchView.swift b/bookstax/Views/Search/SearchView.swift
index 9fd3b95..7b9a0da 100644
--- a/bookstax/Views/Search/SearchView.swift
+++ b/bookstax/Views/Search/SearchView.swift
@@ -15,27 +15,61 @@ struct SearchView: View {
@State private var navigationActive = false
@State private var loadError: BookStackError? = nil
+ @FocusState private var searchFocused: Bool
+
var body: some View {
NavigationStack {
- Group {
- if viewModel.query.isEmpty {
- recentSearchesView
- } else if viewModel.isSearching {
- LoadingView(message: L("search.loading"))
- } else if viewModel.results.isEmpty {
- ContentUnavailableView.search(text: viewModel.query)
- } else {
- resultsList
+ 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 {
+ if viewModel.query.isEmpty {
+ recentSearchesView
+ } else if viewModel.isSearching {
+ LoadingView(message: L("search.loading"))
+ } 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() }
+ .navigationBarTitleDisplayMode(.inline)
+ .task { await viewModel.loadAvailableTags() }
.toolbar {
- if !SearchResultDTO.ContentType.allCases.isEmpty {
- ToolbarItem(placement: .topBarTrailing) {
- filterMenu
- }
+ ToolbarItem(placement: .topBarTrailing) {
+ filterMenu
}
}
.navigationDestination(isPresented: $navigationActive) {
@@ -172,37 +206,58 @@ struct SearchView: View {
// MARK: - Filter Menu
+ private var hasActiveFilter: Bool {
+ viewModel.selectedTypeFilter != nil || viewModel.selectedTagFilter != nil
+ }
+
private var filterMenu: some View {
Menu {
- Button {
- viewModel.selectedTypeFilter = nil
- viewModel.onFilterChanged()
- } label: {
- HStack {
- Text(L("search.filter.all"))
- if viewModel.selectedTypeFilter == nil {
- Image(systemName: "checkmark")
+ // Type filter
+ Section(L("search.filter.type")) {
+ Button {
+ viewModel.selectedTypeFilter = nil
+ viewModel.onFilterChanged()
+ } label: {
+ Label(L("search.filter.all"),
+ systemImage: viewModel.selectedTypeFilter == nil ? "checkmark" : "square")
+ }
+ ForEach(SearchResultDTO.ContentType.allCases, id: \.self) { type in
+ Button {
+ viewModel.selectedTypeFilter = (viewModel.selectedTypeFilter == type) ? nil : type
+ viewModel.onFilterChanged()
+ } label: {
+ Label(type.displayName,
+ systemImage: viewModel.selectedTypeFilter == type ? "checkmark" : type.systemImage)
}
}
}
- 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")
+
+ // 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: {
- Image(systemName: viewModel.selectedTypeFilter == nil
- ? "line.3.horizontal.decrease.circle"
- : "line.3.horizontal.decrease.circle.fill")
+ Image(systemName: hasActiveFilter
+ ? "line.3.horizontal.decrease.circle.fill"
+ : "line.3.horizontal.decrease.circle")
}
.accessibilityLabel(L("search.filter"))
}
@@ -242,6 +297,23 @@ struct SearchResultRow: View {
.lineLimit(2)
.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)
.accessibilityLabel("\(result.type.displayName): \(result.name)")
diff --git a/bookstax/de.lproj/Localizable.strings b/bookstax/de.lproj/Localizable.strings
index b7ef212..f151630 100644
--- a/bookstax/de.lproj/Localizable.strings
+++ b/bookstax/de.lproj/Localizable.strings
@@ -54,11 +54,13 @@
"library.refresh" = "Aktualisieren";
"library.shelves" = "Regale";
"library.updated" = "Aktualisiert %@";
+"library.newshelf" = "Neues Regal";
// 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.";
+"shelf.newbook" = "Neues Buch";
// MARK: - Book
"book.loading" = "Inhalte werden geladen…";
@@ -97,11 +99,10 @@
"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.close" = "Schließen";
"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…";
// MARK: - Search
@@ -197,6 +198,23 @@
"settings.log.viewer.title" = "App-Protokoll";
"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
"common.ok" = "OK";
"common.error" = "Unbekannter Fehler";
+"common.done" = "Fertig";
diff --git a/bookstax/en.lproj/Localizable.strings b/bookstax/en.lproj/Localizable.strings
index 4b64fbc..5d62649 100644
--- a/bookstax/en.lproj/Localizable.strings
+++ b/bookstax/en.lproj/Localizable.strings
@@ -54,11 +54,13 @@
"library.refresh" = "Refresh";
"library.shelves" = "Shelves";
"library.updated" = "Updated %@";
+"library.newshelf" = "New Shelf";
// MARK: - Shelf
"shelf.loading" = "Loading books…";
"shelf.empty.title" = "No Books";
"shelf.empty.message" = "This shelf has no books yet.";
+"shelf.newbook" = "New Book";
// MARK: - Book
"book.loading" = "Loading content…";
@@ -97,11 +99,10 @@
"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.close" = "Close";
"editor.discard.keepediting" = "Keep Editing";
+"editor.close.unsaved.title" = "Close without saving?";
+"editor.close.unsaved.confirm" = "Close";
"editor.image.uploading" = "Uploading image…";
// MARK: - Search
@@ -197,6 +198,23 @@
"settings.log.viewer.title" = "App Log";
"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
"common.ok" = "OK";
"common.error" = "Unknown error";
+"common.done" = "Done";
diff --git a/bookstax/es.lproj/Localizable.strings b/bookstax/es.lproj/Localizable.strings
index 004c528..e7bb684 100644
--- a/bookstax/es.lproj/Localizable.strings
+++ b/bookstax/es.lproj/Localizable.strings
@@ -54,11 +54,13 @@
"library.refresh" = "Actualizar";
"library.shelves" = "Estantes";
"library.updated" = "Actualizado %@";
+"library.newshelf" = "Nuevo estante";
// MARK: - Shelf
"shelf.loading" = "Cargando libros…";
"shelf.empty.title" = "Sin libros";
"shelf.empty.message" = "Este estante aún no tiene libros.";
+"shelf.newbook" = "Nuevo libro";
// MARK: - Book
"book.loading" = "Cargando contenido…";
@@ -97,11 +99,10 @@
"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.close" = "Cerrar";
"editor.discard.keepediting" = "Seguir editando";
+"editor.close.unsaved.title" = "¿Cerrar sin guardar?";
+"editor.close.unsaved.confirm" = "Cerrar";
"editor.image.uploading" = "Subiendo imagen…";
// MARK: - Search
@@ -197,6 +198,23 @@
"settings.log.viewer.title" = "Registro de la app";
"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
"common.ok" = "Aceptar";
"common.error" = "Error desconocido";
+"common.done" = "Listo";