From 8b57d8ff6150ecc60f13cc34264ed8858f72e2d6 Mon Sep 17 00:00:00 2001 From: Sven Date: Sat, 21 Mar 2026 12:02:20 +0100 Subject: [PATCH] =?UTF-8?q?Tags=20hinzugef=C3=BCgt,=20Flow=20angepasst?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bookstax/MockData.swift | 14 +- bookstax/Models/DTOs.swift | 67 ++- bookstax/Services/BookStackAPI.swift | 43 +- bookstax/ViewModels/PageEditorViewModel.swift | 67 ++- bookstax/ViewModels/SearchViewModel.swift | 25 +- bookstax/Views/Editor/PageEditorView.swift | 481 +++++++++++++----- bookstax/Views/Library/BookDetailView.swift | 2 +- bookstax/Views/Library/BooksInShelfView.swift | 17 + bookstax/Views/Library/LibraryView.swift | 17 + .../Views/NewContent/NewContentView.swift | 209 +------- bookstax/Views/Reader/PageReaderView.swift | 6 +- bookstax/Views/Search/SearchView.swift | 144 ++++-- bookstax/de.lproj/Localizable.strings | 26 +- bookstax/en.lproj/Localizable.strings | 26 +- bookstax/es.lproj/Localizable.strings | 26 +- 15 files changed, 750 insertions(+), 420 deletions(-) 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";