feat(collections): add tabbed overview and document reader (macOS)
CollectionOverviewView mirrors Outline's own collection tabs (Overview, Documents, Popular, sort variants), with the Overview tab rendering the collection's description through MarkdownEngine. DocumentReaderView opens a tapped document read-only via the same engine, re-fetching full content through documents.info rather than trusting the list payload.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import MarkdownEngine
|
||||
import OutlineKit
|
||||
|
||||
/// Read-only for now — document/overview editing isn't wired up yet. Uses
|
||||
/// `NativeTextViewWrapper`'s own `isEditable: false`, not `.disabled(true)`:
|
||||
/// the latter blocks ALL interaction including link clicks, since
|
||||
/// `isSelectable` and link-opening both still need to work.
|
||||
struct CollectionOverviewContent: View {
|
||||
@State private var markdown: String
|
||||
|
||||
init(collection: OutlineCollection) {
|
||||
_markdown = State(initialValue: collection.description ?? "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
NativeTextViewWrapper(
|
||||
text: $markdown,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
isEditable: false
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,153 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
struct CollectionOverviewView: View {
|
||||
let collection: OutlineCollection
|
||||
@State private var viewModel: DocumentsViewModel
|
||||
@State private var selectedTab: CollectionTab = .overview
|
||||
|
||||
// Contextual search — "search what you're looking at" — scoped to this
|
||||
// collection's documents. The field itself lives on the parent
|
||||
// NavigationStack (so it survives pushing into a document); this owns
|
||||
// only the resulting search state.
|
||||
@State private var searchViewModel: DocumentTitleSearchViewModel
|
||||
@Binding var searchQuery: String
|
||||
|
||||
private var trimmedSearchQuery: String {
|
||||
searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
init(apiClient: OutlineAPIClient, collection: OutlineCollection, searchQuery: Binding<String>) {
|
||||
self.collection = collection
|
||||
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
|
||||
_searchQuery = searchQuery
|
||||
}
|
||||
|
||||
private var sortedDocuments: [OutlineDocument] {
|
||||
selectedTab.sorted(viewModel.documents)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// No in-content header — the collection's icon/title live in the
|
||||
// window toolbar now (via `ContentView_macOS`), so this doesn't
|
||||
// duplicate it directly below.
|
||||
if trimmedSearchQuery.isEmpty {
|
||||
tabBar
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// All branches get the same frame so switching tabs/search doesn't
|
||||
// visibly resize/jump the content area to its own ideal size.
|
||||
Group {
|
||||
if !trimmedSearchQuery.isEmpty {
|
||||
searchResultsList
|
||||
} else if selectedTab == .overview {
|
||||
CollectionOverviewContent(collection: collection)
|
||||
} else {
|
||||
documentList
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
// No `.navigationTitle` here — it renders its own native title bubble,
|
||||
// duplicating the leading toolbar item `ContentView_macOS` already
|
||||
// shows (which also carries the collection > document hierarchy).
|
||||
.task { await viewModel.load() }
|
||||
.task(id: trimmedSearchQuery) {
|
||||
try? await Task.sleep(for: .milliseconds(250))
|
||||
guard !Task.isCancelled else { return }
|
||||
await searchViewModel.search(query: trimmedSearchQuery)
|
||||
}
|
||||
}
|
||||
|
||||
// Spans the full window width, centered, directly under the toolbar —
|
||||
// not a scrolling, left-aligned row anymore.
|
||||
private var tabBar: some View {
|
||||
HStack(spacing: 4) {
|
||||
Spacer(minLength: 0)
|
||||
ForEach(CollectionTab.allCases) { tab in
|
||||
Button {
|
||||
selectedTab = tab
|
||||
} label: {
|
||||
Text(tab.label)
|
||||
.font(.callout.weight(selectedTab == tab ? .semibold : .regular))
|
||||
.foregroundStyle(selectedTab == tab ? Color.primary : Color.secondary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 6)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var documentList: some View {
|
||||
if viewModel.isLoading && viewModel.documents.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let errorMessage = viewModel.errorMessage, viewModel.documents.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Retry") {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
}
|
||||
} else if viewModel.documents.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Documents",
|
||||
systemImage: "doc.text",
|
||||
description: Text("Documents in \(collection.name) will appear here.")
|
||||
)
|
||||
} else {
|
||||
List(sortedDocuments) { document in
|
||||
NavigationLink(value: document) {
|
||||
DocumentRowView(document: document)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var searchResultsList: some View {
|
||||
if searchViewModel.isSearching && searchViewModel.results.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let errorMessage = searchViewModel.errorMessage {
|
||||
ContentUnavailableView {
|
||||
Label("Search Failed", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
}
|
||||
} else if searchViewModel.results.isEmpty {
|
||||
ContentUnavailableView.search(text: trimmedSearchQuery)
|
||||
} else {
|
||||
List(searchViewModel.results) { result in
|
||||
NavigationLink(value: result.document) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
DocumentRowView(document: result.document)
|
||||
Text(result.context)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
import OutlineKit
|
||||
|
||||
/// Mirrors Outline's own top tab bar on a collection's page.
|
||||
enum CollectionTab: String, CaseIterable, Identifiable {
|
||||
case overview
|
||||
case documents
|
||||
case popular
|
||||
case recentlyUpdated
|
||||
case recentlyPublished
|
||||
case leastRecentlyUpdated
|
||||
case alphabetical
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .overview: return "Overview"
|
||||
case .documents: return "Documents"
|
||||
case .popular: return "Popular"
|
||||
case .recentlyUpdated: return "Recently Updated"
|
||||
case .recentlyPublished: return "Recently Published"
|
||||
case .leastRecentlyUpdated: return "Least Recently Updated"
|
||||
case .alphabetical: return "A-Z"
|
||||
}
|
||||
}
|
||||
|
||||
/// `.popular` has no real ranking signal yet — it would need view-count /
|
||||
/// analytics data the REST layer doesn't expose — so it falls back to the
|
||||
/// same order as `.documents` rather than fabricating a ranking.
|
||||
func sorted(_ documents: [OutlineDocument]) -> [OutlineDocument] {
|
||||
switch self {
|
||||
case .overview, .documents, .popular:
|
||||
return documents
|
||||
case .recentlyUpdated:
|
||||
return documents.sorted { $0.updatedAt > $1.updatedAt }
|
||||
case .recentlyPublished:
|
||||
return documents.sorted { ($0.publishedAt ?? $0.createdAt) > ($1.publishedAt ?? $1.createdAt) }
|
||||
case .leastRecentlyUpdated:
|
||||
return documents.sorted { $0.updatedAt < $1.updatedAt }
|
||||
case .alphabetical:
|
||||
return documents.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import MarkdownEngine
|
||||
import OutlineKit
|
||||
|
||||
/// Read-only (`isEditable: false`), matching the Overview tab — document editing
|
||||
/// isn't wired up yet, but link clicks and text selection still work.
|
||||
struct DocumentReaderView: View {
|
||||
@State private var viewModel: DocumentReaderViewModel
|
||||
|
||||
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
||||
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// No in-content header — the document's icon/title live in the
|
||||
// window toolbar now (via `ContentView_macOS`).
|
||||
if viewModel.isLoading && viewModel.text.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if let errorMessage = viewModel.errorMessage {
|
||||
ContentUnavailableView {
|
||||
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Retry") {
|
||||
Task { await viewModel.loadFullContent() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
isEditable: false
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
// No `.navigationTitle` here either — same reason as CollectionOverviewView.
|
||||
.task { await viewModel.loadFullContent() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OutlineKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DocumentReaderViewModel {
|
||||
var title: String
|
||||
var emoji: String?
|
||||
var text: String
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
|
||||
private let apiClient: OutlineAPIClient
|
||||
private let documentId: String
|
||||
|
||||
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
||||
self.apiClient = apiClient
|
||||
self.documentId = document.id
|
||||
self.title = document.title
|
||||
self.emoji = document.emoji
|
||||
self.text = document.text
|
||||
}
|
||||
|
||||
/// The list endpoint's copy of a document isn't guaranteed to be the full,
|
||||
/// current body — always re-fetch via `documents.info` when actually opened.
|
||||
func loadFullContent() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let full = try await apiClient.documentInfo(id: documentId)
|
||||
title = full.title
|
||||
emoji = full.emoji
|
||||
text = full.text
|
||||
} catch {
|
||||
errorMessage = "Couldn't load this document. Check your connection and try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user