Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b3b2db013 | |||
| da22b50ae4 |
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,6 @@ extension SearchResultDTO {
|
||||
extension CommentDTO {
|
||||
static let mock = CommentDTO(
|
||||
id: 1,
|
||||
text: "Great documentation! Very helpful.",
|
||||
html: "<p>Great documentation! Very helpful.</p>",
|
||||
pageId: 1,
|
||||
createdBy: UserSummaryDTO(id: 1, name: "Alice Johnson", avatarUrl: nil),
|
||||
|
||||
@@ -207,7 +207,6 @@ nonisolated struct TagListResponseDTO: Codable, Sendable {
|
||||
|
||||
nonisolated struct CommentDTO: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let text: String
|
||||
let html: String
|
||||
let pageId: Int
|
||||
let createdBy: UserSummaryDTO
|
||||
@@ -215,14 +214,19 @@ nonisolated struct CommentDTO: Codable, Sendable, Identifiable, Hashable {
|
||||
let updatedAt: Date
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, text, html
|
||||
case pageId = "entity_id"
|
||||
case id, html
|
||||
case pageId = "commentable_id"
|
||||
case createdBy = "created_by"
|
||||
case createdAt = "created_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 {
|
||||
let id: Int
|
||||
let name: String
|
||||
|
||||
@@ -317,25 +317,37 @@ actor BookStackAPI {
|
||||
// MARK: - Comments
|
||||
|
||||
func fetchComments(pageId: Int) async throws -> [CommentDTO] {
|
||||
let response: PaginatedResponse<CommentDTO> = try await request(
|
||||
endpoint: "comments?entity_type=page&entity_id=\(pageId)"
|
||||
let list: PaginatedResponse<CommentSummaryDTO> = try await request(
|
||||
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 {
|
||||
let text: String
|
||||
let entityId: Int
|
||||
let entityType: String
|
||||
let pageId: Int
|
||||
let html: String
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case text
|
||||
case entityId = "entity_id"
|
||||
case entityType = "entity_type"
|
||||
case pageId = "page_id"
|
||||
case html
|
||||
}
|
||||
}
|
||||
return try await request(endpoint: "comments", method: "POST",
|
||||
body: Body(text: text, entityId: pageId, entityType: "page"))
|
||||
// The POST response shape differs from CommentDTO — use EmptyResponse and discard it
|
||||
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 {
|
||||
|
||||
@@ -3,28 +3,59 @@ import UIKit
|
||||
import WebKit
|
||||
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
|
||||
|
||||
struct MarkdownTextEditor: UIViewRepresentable {
|
||||
@Binding var text: String
|
||||
/// Called with the UITextView so the parent can apply formatting
|
||||
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 {
|
||||
let tv = UITextView()
|
||||
func makeUIView(context: Context) -> ImagePasteTextView {
|
||||
let tv = ImagePasteTextView()
|
||||
tv.font = UIFont.monospacedSystemFont(ofSize: 16, weight: .regular)
|
||||
tv.autocorrectionType = .no
|
||||
tv.autocapitalizationType = .none
|
||||
tv.delegate = context.coordinator
|
||||
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)
|
||||
tv.text = text
|
||||
tv.onImagePaste = onImagePaste
|
||||
onTextViewReady(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).
|
||||
// Skip updates triggered by the user typing to avoid cursor position resets.
|
||||
guard !context.coordinator.isEditing, tv.text != text else { return }
|
||||
@@ -147,12 +178,15 @@ struct PageEditorView: View {
|
||||
)
|
||||
Rectangle().fill(Color(.separator)).frame(height: 0.5)
|
||||
|
||||
// Content area
|
||||
if viewModel.activeTab == .write {
|
||||
writeArea
|
||||
} else {
|
||||
MarkdownPreviewView(markdown: viewModel.markdownContent)
|
||||
// Content area — fills all remaining vertical space
|
||||
Group {
|
||||
if viewModel.activeTab == .write {
|
||||
writeArea
|
||||
} else {
|
||||
MarkdownPreviewView(markdown: viewModel.markdownContent)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
// Save error
|
||||
if let error = viewModel.saveError {
|
||||
@@ -160,12 +194,22 @@ struct PageEditorView: View {
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var writeArea: some View {
|
||||
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 {
|
||||
HStack(spacing: 8) {
|
||||
@@ -197,7 +241,9 @@ struct PageEditorView: View {
|
||||
isUploadingImage: {
|
||||
if case .uploading = viewModel.imageUploadState { return true }
|
||||
return false
|
||||
}()
|
||||
}(),
|
||||
clipboardHasImage: UIPasteboard.general.hasImages,
|
||||
onPasteImage: { Task { await pasteImageFromClipboard() } }
|
||||
) { 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)
|
||||
|
||||
private func applyFormat(_ action: FormatAction) {
|
||||
@@ -421,6 +476,8 @@ enum FormatAction {
|
||||
struct FormattingToolbar: View {
|
||||
@Binding var imagePickerItem: PhotosPickerItem?
|
||||
let isUploadingImage: Bool
|
||||
let clipboardHasImage: Bool
|
||||
let onPasteImage: () -> Void
|
||||
let onAction: (FormatAction) -> Void
|
||||
|
||||
var body: some View {
|
||||
@@ -429,7 +486,7 @@ struct FormattingToolbar: View {
|
||||
.fill(Color(.separator))
|
||||
.frame(height: 0.5)
|
||||
|
||||
// Row 1: Headings + text formatting
|
||||
// Row 1: Headings + inline formatting
|
||||
HStack(spacing: 0) {
|
||||
FormatButton("H1", action: .h1, onAction: onAction)
|
||||
FormatButton("H2", action: .h2, onAction: onAction)
|
||||
@@ -440,7 +497,7 @@ struct FormattingToolbar: View {
|
||||
FormatButton(systemImage: "strikethrough", action: .strikethrough, onAction: onAction)
|
||||
FormatButton(systemImage: "chevron.left.forwardslash.chevron.right", action: .inlineCode, onAction: onAction)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(maxWidth: .infinity, minHeight: 28)
|
||||
|
||||
Rectangle()
|
||||
.fill(Color(.separator))
|
||||
@@ -456,7 +513,7 @@ struct FormattingToolbar: View {
|
||||
FormatButton(systemImage: "curlybraces", action: .codeBlock, onAction: onAction)
|
||||
FormatButton(systemImage: "minus", action: .horizontalRule, onAction: onAction)
|
||||
toolbarDivider
|
||||
// Image picker
|
||||
// Image picker (from photo library)
|
||||
PhotosPicker(
|
||||
selection: $imagePickerItem,
|
||||
matching: .images,
|
||||
@@ -467,15 +524,23 @@ struct FormattingToolbar: View {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
.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))
|
||||
}
|
||||
@@ -483,8 +548,8 @@ struct FormattingToolbar: View {
|
||||
private var toolbarDivider: some View {
|
||||
Rectangle()
|
||||
.fill(Color(.separator))
|
||||
.frame(width: 0.5)
|
||||
.padding(.vertical, 8)
|
||||
.frame(width: 0.5, height: 16)
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,13 +579,13 @@ struct FormatButton: View {
|
||||
Group {
|
||||
if let label {
|
||||
Text(label)
|
||||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||
} else if let 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)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
@@ -566,7 +631,10 @@ struct MarkdownPreviewView: View {
|
||||
</head>
|
||||
<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.
|
||||
@@ -593,6 +661,14 @@ struct MarkdownPreviewView: View {
|
||||
with: "<h\(h)>$1</h\(h)>",
|
||||
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
|
||||
html = html.replacingOccurrences(of: "(?m)^---$", with: "<hr>",
|
||||
options: .regularExpression)
|
||||
|
||||
@@ -142,7 +142,7 @@ struct BookDetailView: View {
|
||||
.accessibilityLabel("Add content")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showNewPage) {
|
||||
.fullScreenCover(isPresented: $showNewPage) {
|
||||
NavigationStack { PageEditorView(mode: .create(bookId: book.id)) }
|
||||
}
|
||||
.sheet(isPresented: $showNewChapter) {
|
||||
|
||||
@@ -125,6 +125,7 @@ struct BreadcrumbBar: View {
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(theme.accentColor)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
@@ -132,11 +133,12 @@ struct BreadcrumbBar: View {
|
||||
.font(.subheadline.weight(isLast ? .semibold : .medium))
|
||||
.foregroundStyle(isLast ? .primary : .secondary)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ struct PageReaderView: View {
|
||||
@State private var isFetchingForEdit = false
|
||||
@State private var newComment = ""
|
||||
@State private var isPostingComment = false
|
||||
@State private var commentError: String? = nil
|
||||
@State private var commentsExpanded = false
|
||||
@AppStorage("showComments") private var showComments = true
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@@ -23,40 +25,59 @@ struct PageReaderView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Page header
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(resolvedPage.name)
|
||||
.font(.largeTitle.bold())
|
||||
.padding(.horizontal)
|
||||
.padding(.top)
|
||||
VStack(spacing: 0) {
|
||||
// Page header
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(resolvedPage.name)
|
||||
.font(.title2.bold())
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
|
||||
Text(String(format: L("library.updated"), resolvedPage.updatedAt.bookStackRelative))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
// Web content
|
||||
WebView(webPage)
|
||||
.frame(minHeight: 400)
|
||||
.frame(maxWidth: .infinity)
|
||||
Text(String(format: L("library.updated"), resolvedPage.updatedAt.bookStackRelative))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Divider()
|
||||
.padding(.top)
|
||||
}
|
||||
|
||||
// Comments section (hidden when user disabled in Settings)
|
||||
if showComments {
|
||||
DisclosureGroup {
|
||||
commentsContent
|
||||
// Web content — fills all space not taken by the comments inset
|
||||
WebView(webPage)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.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(String(format: L("reader.comments"), comments.count), systemImage: "bubble.left.and.bubble.right")
|
||||
.font(.headline)
|
||||
HStack {
|
||||
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)
|
||||
@@ -89,7 +110,7 @@ struct PageReaderView: View {
|
||||
.accessibilityLabel(L("reader.share"))
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showEditor) {
|
||||
.fullScreenCover(isPresented: $showEditor) {
|
||||
NavigationStack {
|
||||
if let fullPage {
|
||||
PageEditorView(mode: .edit(page: fullPage))
|
||||
@@ -113,42 +134,75 @@ struct PageReaderView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var commentsContent: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if isLoadingComments {
|
||||
ProgressView()
|
||||
VStack(spacing: 0) {
|
||||
// Scrollable comment list — fixed height so layout is stable
|
||||
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)
|
||||
.padding()
|
||||
} else if comments.isEmpty {
|
||||
Text(L("reader.comments.empty"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
} else {
|
||||
ForEach(comments) { comment in
|
||||
CommentRow(comment: comment)
|
||||
Divider()
|
||||
}
|
||||
.frame(height: 180)
|
||||
.onChange(of: comments.count) {
|
||||
if let last = comments.last {
|
||||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New comment input
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField(L("reader.comment.placeholder"), text: $newComment, axis: .vertical)
|
||||
.lineLimit(1...4)
|
||||
.padding(10)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
Button {
|
||||
Task { await postComment() }
|
||||
} label: {
|
||||
Image(systemName: "paperplane.fill")
|
||||
.foregroundStyle(newComment.isEmpty ? Color.secondary : Color.blue)
|
||||
// Input — always visible at the bottom of the panel
|
||||
Divider()
|
||||
VStack(spacing: 4) {
|
||||
if let err = commentError {
|
||||
Text(err)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.disabled(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isPostingComment)
|
||||
.accessibilityLabel("Post comment")
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
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
|
||||
@@ -186,19 +240,26 @@ struct PageReaderView: View {
|
||||
isLoadingComments = true
|
||||
comments = (try? await BookStackAPI.shared.fetchComments(pageId: page.id)) ?? []
|
||||
isLoadingComments = false
|
||||
// Auto-expand if there are comments
|
||||
if !comments.isEmpty { commentsExpanded = true }
|
||||
}
|
||||
|
||||
private func postComment() async {
|
||||
let text = newComment.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
isPostingComment = true
|
||||
commentError = nil
|
||||
AppLog(.info, "Posting comment on page '\(page.name)' (id: \(page.id))", category: "Reader")
|
||||
do {
|
||||
let comment = try await BookStackAPI.shared.postComment(pageId: page.id, text: text)
|
||||
comments.append(comment)
|
||||
try await BookStackAPI.shared.postComment(pageId: page.id, text: text)
|
||||
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 {
|
||||
commentError = error.localizedDescription
|
||||
AppLog(.error, "Failed to post comment on '\(page.name)': \(error.localizedDescription)", category: "Reader")
|
||||
}
|
||||
isPostingComment = false
|
||||
@@ -292,7 +353,7 @@ struct CommentRow: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Text(comment.text)
|
||||
Text(comment.html.strippingHTML)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user