Compare commits

...

2 Commits

Author SHA1 Message Date
sven 6b3b2db013 Toolbar, Editor Window 2026-03-21 18:43:46 +01:00
sven da22b50ae4 Kommentare, Toolbar, Editorfenster 2026-03-21 18:42:53 +01:00
8 changed files with 278 additions and 103 deletions
+21
View File
@@ -0,0 +1,21 @@
import Foundation
import UIKit
extension String {
/// Strips HTML tags and decodes common HTML entities for plain-text display.
var strippingHTML: String {
// Use NSAttributedString to parse HTML handles entities and nested tags correctly
guard let data = data(using: .utf8),
let attributed = try? NSAttributedString(
data: data,
options: [.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue],
documentAttributes: nil)
else {
// Fallback: remove tags with regex
return replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return attributed.string.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
-1
View File
@@ -96,7 +96,6 @@ extension SearchResultDTO {
extension CommentDTO { extension CommentDTO {
static let mock = CommentDTO( static let mock = CommentDTO(
id: 1, id: 1,
text: "Great documentation! Very helpful.",
html: "<p>Great documentation! Very helpful.</p>", html: "<p>Great documentation! Very helpful.</p>",
pageId: 1, pageId: 1,
createdBy: UserSummaryDTO(id: 1, name: "Alice Johnson", avatarUrl: nil), createdBy: UserSummaryDTO(id: 1, name: "Alice Johnson", avatarUrl: nil),
+7 -3
View File
@@ -207,7 +207,6 @@ nonisolated struct TagListResponseDTO: Codable, Sendable {
nonisolated struct CommentDTO: Codable, Sendable, Identifiable, Hashable { nonisolated struct CommentDTO: Codable, Sendable, Identifiable, Hashable {
let id: Int let id: Int
let text: String
let html: String let html: String
let pageId: Int let pageId: Int
let createdBy: UserSummaryDTO let createdBy: UserSummaryDTO
@@ -215,14 +214,19 @@ nonisolated struct CommentDTO: Codable, Sendable, Identifiable, Hashable {
let updatedAt: Date let updatedAt: Date
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, text, html case id, html
case pageId = "entity_id" case pageId = "commentable_id"
case createdBy = "created_by" case createdBy = "created_by"
case createdAt = "created_at" case createdAt = "created_at"
case updatedAt = "updated_at" case updatedAt = "updated_at"
} }
} }
/// Minimal shape returned by the list endpoint only used to get IDs for detail fetches.
nonisolated struct CommentSummaryDTO: Codable, Sendable {
let id: Int
}
nonisolated struct UserSummaryDTO: Codable, Sendable, Hashable { nonisolated struct UserSummaryDTO: Codable, Sendable, Hashable {
let id: Int let id: Int
let name: String let name: String
+24 -12
View File
@@ -317,25 +317,37 @@ actor BookStackAPI {
// MARK: - Comments // MARK: - Comments
func fetchComments(pageId: Int) async throws -> [CommentDTO] { func fetchComments(pageId: Int) async throws -> [CommentDTO] {
let response: PaginatedResponse<CommentDTO> = try await request( let list: PaginatedResponse<CommentSummaryDTO> = try await request(
endpoint: "comments?entity_type=page&entity_id=\(pageId)" endpoint: "comments?filter[commentable_type]=page&filter[commentable_id]=\(pageId)&count=100"
) )
return response.data // Fetch full detail for each comment in parallel to get html + user info
return try await withThrowingTaskGroup(of: CommentDTO.self) { group in
for summary in list.data {
group.addTask {
try await self.request(endpoint: "comments/\(summary.id)")
}
}
var results: [CommentDTO] = []
for try await comment in group { results.append(comment) }
return results.sorted { $0.createdAt < $1.createdAt }
}
} }
func postComment(pageId: Int, text: String) async throws -> CommentDTO { func postComment(pageId: Int, text: String) async throws {
struct Body: Encodable, Sendable { struct Body: Encodable, Sendable {
let text: String let pageId: Int
let entityId: Int let html: String
let entityType: String
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case text case pageId = "page_id"
case entityId = "entity_id" case html
case entityType = "entity_type"
} }
} }
return try await request(endpoint: "comments", method: "POST", // The POST response shape differs from CommentDTO use EmptyResponse and discard it
body: Body(text: text, entityId: pageId, entityType: "page")) struct PostCommentResponse: Decodable, Sendable { let id: Int }
// BookStack expects HTML content wrap plain text in a paragraph tag
let html = "<p>\(text.replacingOccurrences(of: "\n", with: "<br>"))</p>"
let _: PostCommentResponse = try await request(endpoint: "comments", method: "POST",
body: Body(pageId: pageId, html: html))
} }
func deleteComment(id: Int) async throws { func deleteComment(id: Int) async throws {
+99 -23
View File
@@ -3,28 +3,59 @@ import UIKit
import WebKit import WebKit
import PhotosUI import PhotosUI
// MARK: - UITextView subclass that intercepts image paste from the context menu
final class ImagePasteTextView: UITextView {
/// Called when the user selects Paste and the clipboard contains an image.
var onImagePaste: ((UIImage) -> Void)?
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
// Show the standard Paste item whenever the clipboard has an image OR text.
if action == #selector(paste(_:)) {
return UIPasteboard.general.hasImages || UIPasteboard.general.hasStrings
}
return super.canPerformAction(action, withSender: sender)
}
override func paste(_ sender: Any?) {
// If there is an image on the clipboard, intercept and forward it.
if let image = UIPasteboard.general.image {
onImagePaste?(image)
} else {
super.paste(sender)
}
}
}
// MARK: - UITextView wrapper that exposes selection-aware formatting // MARK: - UITextView wrapper that exposes selection-aware formatting
struct MarkdownTextEditor: UIViewRepresentable { struct MarkdownTextEditor: UIViewRepresentable {
@Binding var text: String @Binding var text: String
/// Called with the UITextView so the parent can apply formatting /// Called with the UITextView so the parent can apply formatting
var onTextViewReady: (UITextView) -> Void var onTextViewReady: (UITextView) -> Void
/// Called when the user pastes an image via the context menu
var onImagePaste: ((UIImage) -> Void)? = nil
func makeUIView(context: Context) -> UITextView { func makeUIView(context: Context) -> ImagePasteTextView {
let tv = UITextView() let tv = ImagePasteTextView()
tv.font = UIFont.monospacedSystemFont(ofSize: 16, weight: .regular) tv.font = UIFont.monospacedSystemFont(ofSize: 16, weight: .regular)
tv.autocorrectionType = .no tv.autocorrectionType = .no
tv.autocapitalizationType = .none tv.autocapitalizationType = .none
tv.delegate = context.coordinator tv.delegate = context.coordinator
tv.backgroundColor = .clear tv.backgroundColor = .clear
tv.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) tv.isScrollEnabled = true
tv.alwaysBounceVertical = true
tv.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 80, right: 8)
// Set initial text (e.g. when editing an existing page) // Set initial text (e.g. when editing an existing page)
tv.text = text tv.text = text
tv.onImagePaste = onImagePaste
onTextViewReady(tv) onTextViewReady(tv)
return tv return tv
} }
func updateUIView(_ tv: UITextView, context: Context) { func updateUIView(_ tv: ImagePasteTextView, context: Context) {
// Keep the paste closure up to date (captures may change across renders)
tv.onImagePaste = onImagePaste
// Only push changes that originated outside the UITextView (e.g. formatting toolbar). // Only push changes that originated outside the UITextView (e.g. formatting toolbar).
// Skip updates triggered by the user typing to avoid cursor position resets. // Skip updates triggered by the user typing to avoid cursor position resets.
guard !context.coordinator.isEditing, tv.text != text else { return } guard !context.coordinator.isEditing, tv.text != text else { return }
@@ -147,12 +178,15 @@ struct PageEditorView: View {
) )
Rectangle().fill(Color(.separator)).frame(height: 0.5) Rectangle().fill(Color(.separator)).frame(height: 0.5)
// Content area // Content area fills all remaining vertical space
if viewModel.activeTab == .write { Group {
writeArea if viewModel.activeTab == .write {
} else { writeArea
MarkdownPreviewView(markdown: viewModel.markdownContent) } else {
MarkdownPreviewView(markdown: viewModel.markdownContent)
}
} }
.frame(maxHeight: .infinity)
// Save error // Save error
if let error = viewModel.saveError { if let error = viewModel.saveError {
@@ -160,12 +194,22 @@ struct PageEditorView: View {
.padding() .padding()
} }
} }
.frame(maxHeight: .infinity)
} }
@ViewBuilder @ViewBuilder
private var writeArea: some View { private var writeArea: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
MarkdownTextEditor(text: $viewModel.markdownContent) { tv in textView = tv } MarkdownTextEditor(text: $viewModel.markdownContent,
onTextViewReady: { tv in textView = tv },
onImagePaste: { image in
Task {
let data = image.jpegData(compressionQuality: 0.85) ?? Data()
guard !data.isEmpty else { return }
await viewModel.uploadImage(data: data, filename: "clipboard.jpg", mimeType: "image/jpeg")
}
})
.frame(maxHeight: .infinity)
if case .uploading = viewModel.imageUploadState { if case .uploading = viewModel.imageUploadState {
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -197,7 +241,9 @@ struct PageEditorView: View {
isUploadingImage: { isUploadingImage: {
if case .uploading = viewModel.imageUploadState { return true } if case .uploading = viewModel.imageUploadState { return true }
return false return false
}() }(),
clipboardHasImage: UIPasteboard.general.hasImages,
onPasteImage: { Task { await pasteImageFromClipboard() } }
) { action in applyFormat(action) } ) { action in applyFormat(action) }
} }
} }
@@ -245,6 +291,15 @@ struct PageEditorView: View {
} }
} }
// MARK: - Paste image from clipboard
private func pasteImageFromClipboard() async {
guard let uiImage = UIPasteboard.general.image else { return }
let data = uiImage.jpegData(compressionQuality: 0.85) ?? Data()
guard !data.isEmpty else { return }
await viewModel.uploadImage(data: data, filename: "clipboard.jpg", mimeType: "image/jpeg")
}
// MARK: - Apply formatting to selected text (or insert at cursor) // MARK: - Apply formatting to selected text (or insert at cursor)
private func applyFormat(_ action: FormatAction) { private func applyFormat(_ action: FormatAction) {
@@ -421,6 +476,8 @@ enum FormatAction {
struct FormattingToolbar: View { struct FormattingToolbar: View {
@Binding var imagePickerItem: PhotosPickerItem? @Binding var imagePickerItem: PhotosPickerItem?
let isUploadingImage: Bool let isUploadingImage: Bool
let clipboardHasImage: Bool
let onPasteImage: () -> Void
let onAction: (FormatAction) -> Void let onAction: (FormatAction) -> Void
var body: some View { var body: some View {
@@ -429,7 +486,7 @@ struct FormattingToolbar: View {
.fill(Color(.separator)) .fill(Color(.separator))
.frame(height: 0.5) .frame(height: 0.5)
// Row 1: Headings + text formatting // Row 1: Headings + inline formatting
HStack(spacing: 0) { 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)
@@ -440,7 +497,7 @@ struct FormattingToolbar: View {
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) .frame(maxWidth: .infinity, minHeight: 28)
Rectangle() Rectangle()
.fill(Color(.separator)) .fill(Color(.separator))
@@ -456,7 +513,7 @@ struct FormattingToolbar: View {
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 (from photo library)
PhotosPicker( PhotosPicker(
selection: $imagePickerItem, selection: $imagePickerItem,
matching: .images, matching: .images,
@@ -467,15 +524,23 @@ struct FormattingToolbar: View {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
} else { } else {
Image(systemName: "photo") Image(systemName: "photo")
.font(.system(size: 14, weight: .regular)) .font(.system(size: 13, weight: .regular))
} }
} }
.frame(maxWidth: .infinity, minHeight: 36) .frame(maxWidth: .infinity, minHeight: 28)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.disabled(isUploadingImage) .disabled(isUploadingImage)
// Paste image from clipboard
Button(action: onPasteImage) {
Image(systemName: clipboardHasImage ? "doc.on.clipboard.fill" : "doc.on.clipboard")
.font(.system(size: 13, weight: .regular))
.frame(maxWidth: .infinity, minHeight: 28)
.foregroundStyle(clipboardHasImage ? .primary : .tertiary)
}
.disabled(!clipboardHasImage || isUploadingImage)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity, minHeight: 28)
} }
.background(Color(.systemBackground)) .background(Color(.systemBackground))
} }
@@ -483,8 +548,8 @@ struct FormattingToolbar: View {
private var toolbarDivider: some View { private var toolbarDivider: some View {
Rectangle() Rectangle()
.fill(Color(.separator)) .fill(Color(.separator))
.frame(width: 0.5) .frame(width: 0.5, height: 16)
.padding(.vertical, 8) .padding(.horizontal, 2)
} }
} }
@@ -514,13 +579,13 @@ struct FormatButton: View {
Group { Group {
if let label { if let label {
Text(label) Text(label)
.font(.system(size: 12, weight: .medium, design: .rounded)) .font(.system(size: 11, weight: .medium, design: .rounded))
} else if let systemImage { } else if let systemImage {
Image(systemName: systemImage) Image(systemName: systemImage)
.font(.system(size: 14, weight: .regular)) .font(.system(size: 13, weight: .regular))
} }
} }
.frame(maxWidth: .infinity, minHeight: 36) .frame(maxWidth: .infinity, minHeight: 28)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
@@ -566,7 +631,10 @@ struct MarkdownPreviewView: View {
</head> </head>
<body>\(html)</body></html> <body>\(html)</body></html>
""" """
webPage.load(html: fullHTML, baseURL: URL(string: "https://bookstack.example.com")!) // Use the real server URL as base so WKWebView permits loading images from it.
let serverBase = UserDefaults.standard.string(forKey: "serverURL").flatMap { URL(string: $0) }
?? URL(string: "about:blank")!
webPage.load(html: fullHTML, baseURL: serverBase)
} }
/// Minimal Markdown HTML converter for preview purposes. /// Minimal Markdown HTML converter for preview purposes.
@@ -593,6 +661,14 @@ struct MarkdownPreviewView: View {
with: "<h\(h)>$1</h\(h)>", with: "<h\(h)>$1</h\(h)>",
options: .regularExpression) options: .regularExpression)
} }
// Images (must come before links to avoid partial matches)
html = html.replacingOccurrences(of: #"!\[([^\]]*)\]\(([^)]+)\)"#,
with: "<img src=\"$2\" alt=\"$1\" style=\"max-width:100%;height:auto;border-radius:6px;\">",
options: .regularExpression)
// Links
html = html.replacingOccurrences(of: #"\[([^\]]+)\]\(([^)]+)\)"#,
with: "<a href=\"$2\">$1</a>",
options: .regularExpression)
// Horizontal rule // Horizontal rule
html = html.replacingOccurrences(of: "(?m)^---$", with: "<hr>", html = html.replacingOccurrences(of: "(?m)^---$", with: "<hr>",
options: .regularExpression) options: .regularExpression)
+1 -1
View File
@@ -142,7 +142,7 @@ struct BookDetailView: View {
.accessibilityLabel("Add content") .accessibilityLabel("Add content")
} }
} }
.sheet(isPresented: $showNewPage) { .fullScreenCover(isPresented: $showNewPage) {
NavigationStack { PageEditorView(mode: .create(bookId: book.id)) } NavigationStack { PageEditorView(mode: .create(bookId: book.id)) }
} }
.sheet(isPresented: $showNewChapter) { .sheet(isPresented: $showNewChapter) {
+4 -2
View File
@@ -125,6 +125,7 @@ struct BreadcrumbBar: View {
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
.foregroundStyle(theme.accentColor) .foregroundStyle(theme.accentColor)
.lineLimit(1) .lineLimit(1)
.fixedSize()
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} else { } else {
@@ -132,11 +133,12 @@ struct BreadcrumbBar: View {
.font(.subheadline.weight(isLast ? .semibold : .medium)) .font(.subheadline.weight(isLast ? .semibold : .medium))
.foregroundStyle(isLast ? .primary : .secondary) .foregroundStyle(isLast ? .primary : .secondary)
.lineLimit(1) .lineLimit(1)
.fixedSize()
} }
} }
} }
.padding(.horizontal, 4) .padding(.horizontal, 16)
.padding(.vertical, 2) .padding(.vertical, 8)
} }
} }
} }
+122 -61
View File
@@ -12,6 +12,8 @@ struct PageReaderView: View {
@State private var isFetchingForEdit = false @State private var isFetchingForEdit = false
@State private var newComment = "" @State private var newComment = ""
@State private var isPostingComment = false @State private var isPostingComment = false
@State private var commentError: String? = nil
@State private var commentsExpanded = false
@AppStorage("showComments") private var showComments = true @AppStorage("showComments") private var showComments = true
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@@ -23,40 +25,59 @@ struct PageReaderView: View {
} }
var body: some View { var body: some View {
ScrollView { VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 0) { // Page header
// Page header VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: 4) { Text(resolvedPage.name)
Text(resolvedPage.name) .font(.title2.bold())
.font(.largeTitle.bold()) .padding(.horizontal)
.padding(.horizontal) .padding(.top, 12)
.padding(.top)
Text(String(format: L("library.updated"), resolvedPage.updatedAt.bookStackRelative)) Text(String(format: L("library.updated"), resolvedPage.updatedAt.bookStackRelative))
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal) .padding(.horizontal)
.padding(.bottom, 8) .padding(.bottom, 8)
}
// Web content
WebView(webPage)
.frame(minHeight: 400)
.frame(maxWidth: .infinity)
Divider() Divider()
.padding(.top) }
// Comments section (hidden when user disabled in Settings) // Web content fills all space not taken by the comments inset
if showComments { WebView(webPage)
DisclosureGroup { .frame(maxWidth: .infinity, maxHeight: .infinity)
commentsContent }
.safeAreaInset(edge: .bottom, spacing: 0) {
// Comments panel sits above the tab bar, inside the safe area
if showComments {
VStack(spacing: 0) {
Divider()
// Header row always visible, tap to expand/collapse
Button {
withAnimation(.easeInOut(duration: 0.2)) { commentsExpanded.toggle() }
} label: { } label: {
Label(String(format: L("reader.comments"), comments.count), systemImage: "bubble.left.and.bubble.right") HStack {
.font(.headline) Label(String(format: L("reader.comments"), comments.count),
systemImage: "bubble.left.and.bubble.right")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Spacer()
Image(systemName: commentsExpanded ? "chevron.down" : "chevron.up")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 10)
}
.buttonStyle(.plain)
// Expandable body
if commentsExpanded {
Divider()
commentsContent
.transition(.move(edge: .bottom).combined(with: .opacity))
} }
.padding()
} }
.background(Color(.secondarySystemBackground))
} }
} }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -89,7 +110,7 @@ struct PageReaderView: View {
.accessibilityLabel(L("reader.share")) .accessibilityLabel(L("reader.share"))
} }
} }
.sheet(isPresented: $showEditor) { .fullScreenCover(isPresented: $showEditor) {
NavigationStack { NavigationStack {
if let fullPage { if let fullPage {
PageEditorView(mode: .edit(page: fullPage)) PageEditorView(mode: .edit(page: fullPage))
@@ -113,42 +134,75 @@ struct PageReaderView: View {
@ViewBuilder @ViewBuilder
private var commentsContent: some View { private var commentsContent: some View {
VStack(alignment: .leading, spacing: 12) { VStack(spacing: 0) {
if isLoadingComments { // Scrollable comment list fixed height so layout is stable
ProgressView() ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if isLoadingComments {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
} else if comments.isEmpty {
Text(L("reader.comments.empty"))
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding()
} else {
ForEach(comments) { comment in
CommentRow(comment: comment)
.padding(.horizontal)
.id(comment.id)
Divider().padding(.leading, 54)
}
}
}
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding() }
} else if comments.isEmpty { .frame(height: 180)
Text(L("reader.comments.empty")) .onChange(of: comments.count) {
.font(.footnote) if let last = comments.last {
.foregroundStyle(.secondary) withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
.padding() }
} else {
ForEach(comments) { comment in
CommentRow(comment: comment)
Divider()
} }
} }
// New comment input // Input always visible at the bottom of the panel
HStack(alignment: .bottom, spacing: 8) { Divider()
TextField(L("reader.comment.placeholder"), text: $newComment, axis: .vertical) VStack(spacing: 4) {
.lineLimit(1...4) if let err = commentError {
.padding(10) Text(err)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10)) .font(.caption)
.foregroundStyle(.red)
Button { .frame(maxWidth: .infinity, alignment: .leading)
Task { await postComment() } .padding(.horizontal)
} label: { .padding(.top, 4)
Image(systemName: "paperplane.fill")
.foregroundStyle(newComment.isEmpty ? Color.secondary : Color.blue)
} }
.disabled(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isPostingComment) HStack(alignment: .bottom, spacing: 8) {
.accessibilityLabel("Post comment") TextField(L("reader.comment.placeholder"), text: $newComment, axis: .vertical)
.lineLimit(1...3)
.padding(8)
.background(Color(.tertiarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
Button {
Task { await postComment() }
} label: {
if isPostingComment {
ProgressView().controlSize(.small)
} else {
Image(systemName: "paperplane.fill")
.foregroundStyle(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.secondary : Color.accentColor)
}
}
.disabled(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isPostingComment)
.accessibilityLabel("Post comment")
}
.padding(.horizontal)
.padding(.vertical, 8)
} }
.padding(.top, 8) .background(Color(.secondarySystemBackground))
} }
.padding(.top, 8)
} }
// MARK: - Helpers // MARK: - Helpers
@@ -186,19 +240,26 @@ struct PageReaderView: View {
isLoadingComments = true isLoadingComments = true
comments = (try? await BookStackAPI.shared.fetchComments(pageId: page.id)) ?? [] comments = (try? await BookStackAPI.shared.fetchComments(pageId: page.id)) ?? []
isLoadingComments = false isLoadingComments = false
// Auto-expand if there are comments
if !comments.isEmpty { commentsExpanded = true }
} }
private func postComment() async { private func postComment() async {
let text = newComment.trimmingCharacters(in: .whitespacesAndNewlines) let text = newComment.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return } guard !text.isEmpty else { return }
isPostingComment = true isPostingComment = true
commentError = nil
AppLog(.info, "Posting comment on page '\(page.name)' (id: \(page.id))", category: "Reader") AppLog(.info, "Posting comment on page '\(page.name)' (id: \(page.id))", category: "Reader")
do { do {
let comment = try await BookStackAPI.shared.postComment(pageId: page.id, text: text) try await BookStackAPI.shared.postComment(pageId: page.id, text: text)
comments.append(comment)
newComment = "" newComment = ""
AppLog(.info, "Comment posted on '\(page.name)'", category: "Reader") AppLog(.info, "Comment posted on '\(page.name)' — reloading", category: "Reader")
await loadComments()
} catch let e as BookStackError {
commentError = e.localizedDescription
AppLog(.error, "Failed to post comment on '\(page.name)': \(e.localizedDescription)", category: "Reader")
} catch { } catch {
commentError = error.localizedDescription
AppLog(.error, "Failed to post comment on '\(page.name)': \(error.localizedDescription)", category: "Reader") AppLog(.error, "Failed to post comment on '\(page.name)': \(error.localizedDescription)", category: "Reader")
} }
isPostingComment = false isPostingComment = false
@@ -292,7 +353,7 @@ struct CommentRow: View {
.font(.caption) .font(.caption)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
} }
Text(comment.text) Text(comment.html.strippingHTML)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }