Tags hinzugefügt, Flow angepasst

This commit is contained in:
2026-03-21 12:02:20 +01:00
parent 585c7a7ab0
commit 8b57d8ff61
15 changed files with 750 additions and 420 deletions
+8 -6
View File
@@ -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: [])
] ]
} }
+66 -1
View File
@@ -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 {
+32 -11
View File
@@ -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
+56 -11
View File
@@ -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
+22 -3
View File
@@ -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)
+343 -138
View File
@@ -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)) }
} }
+1 -1
View File
@@ -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) }
} }
} }
+17
View File
@@ -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)
} }
+10 -199
View File
@@ -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()
+91 -19
View File
@@ -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)")
+22 -4
View File
@@ -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";
+22 -4
View File
@@ -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";
+22 -4
View File
@@ -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";