369 lines
14 KiB
Swift
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)
|
|
}
|
|
}
|