The reader's inline "New Document" button and Home's New Document sheet only navigated to the new document — neither had a handle on the sidebar row it landed under, so the tree stayed stale until the periodic remote-changes poll (45s) surfaced the "reload" banner. New Document flows that already have a direct handle on their own sidebar row (right-click a collection, right-click a document) already refreshed correctly and are untouched. Threads a documentsChangedToken from ContentView_macOS down through CollectionsTreeView -> CollectionTreeRow -> CollectionDocumentsOutline as externalRefreshToken; every expanded row reloads itself when it bumps, since neither Home nor the reader knows which row (if any) corresponds to where the new document landed.
170 lines
6.5 KiB
Swift
170 lines
6.5 KiB
Swift
#if os(macOS)
|
|
import SwiftUI
|
|
import OutlineKit
|
|
|
|
struct HomeView: View {
|
|
let apiClient: OutlineAPIClient
|
|
let onOpenDocument: (OutlineDocument) -> Void
|
|
/// Home has no sidebar row of its own to reload directly — this tells
|
|
/// the sidebar a document exists now so it can pick it up. See
|
|
/// `CollectionDocumentsOutline.externalRefreshToken`.
|
|
let onDocumentCreated: () -> Void
|
|
|
|
@State private var viewModel: HomeViewModel
|
|
@State private var selectedTab: HomeTab = .recentlyViewed
|
|
@State private var isShowingNewDocumentSheet = false
|
|
|
|
private let pinnedGridColumns = [GridItem(.adaptive(minimum: 260), spacing: 8)]
|
|
private let tabGridColumns = [GridItem(.adaptive(minimum: 220), spacing: 12)]
|
|
|
|
init(apiClient: OutlineAPIClient, onOpenDocument: @escaping (OutlineDocument) -> Void, onDocumentCreated: @escaping () -> Void) {
|
|
self.apiClient = apiClient
|
|
self.onOpenDocument = onOpenDocument
|
|
self.onDocumentCreated = onDocumentCreated
|
|
_viewModel = State(initialValue: HomeViewModel(apiClient: apiClient))
|
|
}
|
|
|
|
private var isShowingPinnedSection: Bool {
|
|
viewModel.isLoadingPinned || !viewModel.pinnedDocuments.isEmpty
|
|
}
|
|
|
|
var body: some View {
|
|
// The pinned section claims roughly the top half when it has
|
|
// anything to show (scrolling within itself if there are enough
|
|
// pinned documents to overflow that), and collapses away entirely
|
|
// when there's nothing pinned so the tabs get the full height.
|
|
GeometryReader { proxy in
|
|
VStack(spacing: 0) {
|
|
if isShowingPinnedSection {
|
|
pinnedSection
|
|
.frame(height: max(proxy.size.height / 2, 180))
|
|
Divider()
|
|
}
|
|
tabSection
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
isShowingNewDocumentSheet = true
|
|
} label: {
|
|
Image(systemName: "doc.badge.plus")
|
|
}
|
|
.help("New Document")
|
|
}
|
|
}
|
|
.sheet(isPresented: $isShowingNewDocumentSheet) {
|
|
NewDocumentSheet(apiClient: apiClient) { document in
|
|
onDocumentCreated()
|
|
onOpenDocument(document)
|
|
}
|
|
}
|
|
.task { await viewModel.loadPinned() }
|
|
.task(id: selectedTab) { await viewModel.load(tab: selectedTab) }
|
|
}
|
|
|
|
private var pinnedSection: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Pinned")
|
|
.font(.title3.weight(.semibold))
|
|
.padding(.horizontal, 24)
|
|
.padding(.top, 20)
|
|
|
|
if viewModel.isLoadingPinned {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
ScrollView {
|
|
LazyVGrid(columns: pinnedGridColumns, spacing: 8) {
|
|
ForEach(viewModel.pinnedDocuments) { document in
|
|
Button {
|
|
onOpenDocument(document)
|
|
} label: {
|
|
PinnedDocumentCard(document: document)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.padding(.bottom, 16)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var tabSection: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
tabBar
|
|
|
|
Divider()
|
|
|
|
let documents = viewModel.documents(for: selectedTab)
|
|
|
|
Group {
|
|
if viewModel.isLoadingTab && documents.isEmpty {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let errorMessage = viewModel.errorMessage, documents.isEmpty {
|
|
ContentUnavailableView {
|
|
Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle")
|
|
} description: {
|
|
Text(errorMessage)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if documents.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Documents",
|
|
systemImage: "doc.text",
|
|
description: Text("Nothing to show here yet.")
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
ScrollView {
|
|
LazyVGrid(columns: tabGridColumns, spacing: 12) {
|
|
ForEach(documents) { document in
|
|
Button {
|
|
onOpenDocument(document)
|
|
} label: {
|
|
DocumentCardView(document: document)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(24)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
// Mirrors `CollectionOverviewView.tabBar`'s exact style, rather than the
|
|
// native `.pickerStyle(.segmented)` this started with.
|
|
private var tabBar: some View {
|
|
HStack(spacing: 4) {
|
|
Spacer(minLength: 0)
|
|
ForEach(HomeTab.allCases) { tab in
|
|
Button {
|
|
selectedTab = tab
|
|
} label: {
|
|
Text(tab.rawValue)
|
|
.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)
|
|
}
|
|
}
|
|
#endif
|