Files
bookstax/bookstax/Views/Reader/PageReaderView.swift
T
2026-04-20 09:41:18 +02:00

369 lines
14 KiB
Swift

import SwiftUI
struct PageReaderView: View {
let page: PageDTO
@State private var htmlContent: String = ""
@State private var fullPage: PageDTO? = nil
@State private var isLoadingPage = false
@State private var comments: [CommentDTO] = []
@State private var isLoadingComments = false
@State private var pageForEditing: PageDTO? = nil
@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
/// The resolved page full version once fetched, summary until then.
private var resolvedPage: PageDTO { fullPage ?? page }
private var serverURL: String {
UserDefaults.standard.string(forKey: "serverURL") ?? "https://bookstack.example.com"
}
var body: some View {
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)
Divider()
}
// Web content fills all space not taken by the comments inset
HTMLWebView(html: htmlContent, baseURL: URL(string: serverURL))
.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: {
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))
}
}
.background(Color(.secondarySystemBackground))
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
Task { await openEditor() }
} label: {
if isFetchingForEdit {
ProgressView().scaleEffect(0.7)
} else {
Image(systemName: "pencil")
}
}
.disabled(isFetchingForEdit)
.accessibilityLabel(L("reader.edit"))
}
ToolbarItem(placement: .topBarTrailing) {
ShareLink(item: "\(serverURL)/books/\(resolvedPage.bookId)/page/\(resolvedPage.slug)") {
Image(systemName: "square.and.arrow.up")
}
.accessibilityLabel(L("reader.share"))
}
}
.fullScreenCover(item: $pageForEditing) { pageToEdit in
NavigationStack {
PageEditorView(mode: .edit(page: pageToEdit))
}
}
.task(id: page.id) {
await loadFullPage()
await loadComments()
}
.onChange(of: pageForEditing) { _, newValue in
// Reload page content after editor is dismissed
if newValue == nil { Task { await loadFullPage() } }
}
.onChange(of: colorScheme) {
loadContent()
}
}
// MARK: - Comments UI
@ViewBuilder
private var commentsContent: some View {
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)
}
.frame(height: 180)
.onChange(of: comments.count) {
if let last = comments.last {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
}
// 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)
}
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)
}
.background(Color(.secondarySystemBackground))
}
}
// MARK: - Helpers
private func loadFullPage() async {
isLoadingPage = true
AppLog(.info, "Loading page content for '\(page.name)' (id: \(page.id))", category: "Reader")
do {
fullPage = try await BookStackAPI.shared.fetchPage(id: page.id)
AppLog(.info, "Page content loaded for '\(page.name)'", category: "Reader")
} catch {
// Leave fullPage = nil so the editor will re-fetch on demand rather than
// receiving the list summary (which has no markdown content).
AppLog(.warning, "Failed to load full page '\(page.name)': \(error.localizedDescription)", category: "Reader")
}
isLoadingPage = false
loadContent()
}
private func openEditor() async {
// Always fetch the full page before opening the editor to guarantee we have markdown content.
// Clear pageForEditing at the start to ensure clean state.
pageForEditing = nil
isFetchingForEdit = true
do {
let fetchedPage = try await BookStackAPI.shared.fetchPage(id: page.id)
AppLog(.info, "Fetched full page content for editing: '\(page.name)'", category: "Reader")
// Only set pageForEditing after successful fetch this triggers the sheet to appear.
// Also update fullPage so the reader view has fresh content when we return.
fullPage = fetchedPage
pageForEditing = fetchedPage
} catch {
AppLog(.error, "Could not load page '\(page.name)' for editing: \(error.localizedDescription)", category: "Reader")
// Don't set pageForEditing sheet will not appear, user stays in reader.
}
isFetchingForEdit = false
}
private func loadContent() {
htmlContent = buildHTML(content: resolvedPage.html ?? "<p><em>\(L("reader.nocontent"))</em></p>")
}
private func loadComments() async {
isLoadingComments = true
comments = (try? await BookStackAPI.shared.fetchComments(pageId: page.id)) ?? []
isLoadingComments = false
}
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 {
try await BookStackAPI.shared.postComment(pageId: page.id, text: text)
newComment = ""
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
}
private func buildHTML(content: String) -> String {
let isDark = colorScheme == .dark
let bg = isDark ? "#1c1c1e" : "#ffffff"
let fg = isDark ? "#f2f2f7" : "#000000"
let codeBg = isDark ? "#2c2c2e" : "#f2f2f7"
let border = isDark ? "#3a3a3c" : "#d1d1d6"
return """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, sans-serif;
font-size: 16px;
line-height: 1.6;
padding: 16px;
max-width: 800px;
margin: 0 auto;
background-color: \(bg);
color: \(fg);
}
img { max-width: 100%; height: auto; border-radius: 6px; }
pre {
overflow-x: auto;
padding: 14px;
background: \(codeBg);
border-radius: 10px;
font-size: 14px;
}
code {
font-family: "SF Mono", "Menlo", monospace;
font-size: 14px;
background: \(codeBg);
padding: 2px 5px;
border-radius: 4px;
}
pre code { background: transparent; padding: 0; }
a { color: #0a84ff; text-decoration: none; }
a:hover { text-decoration: underline; }
blockquote {
border-left: 4px solid #0a84ff;
margin: 16px 0;
padding: 8px 16px;
color: #8e8e93;
}
table { border-collapse: collapse; width: 100%; margin: 16px 0; }
th, td { border: 1px solid \(border); padding: 10px 12px; text-align: left; }
th { background: \(codeBg); font-weight: 600; }
h1, h2, h3, h4, h5, h6 { line-height: 1.3; }
hr { border: none; border-top: 1px solid \(border); margin: 24px 0; }
</style>
</head>
<body>\(content)</body>
</html>
"""
}
}
// MARK: - Comment Row
struct CommentRow: View {
let comment: CommentDTO
var body: some View {
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(.blue.gradient)
.frame(width: 32, height: 32)
.overlay {
Text(comment.createdBy.name.prefix(1).uppercased())
.font(.footnote.bold())
.foregroundStyle(.white)
}
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(comment.createdBy.name)
.font(.footnote.bold())
Spacer()
Text(comment.createdAt.bookStackRelative)
.font(.caption)
.foregroundStyle(.tertiary)
}
Text(comment.html.strippingHTML)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
}
#Preview {
NavigationStack {
PageReaderView(page: .mock)
}
}