Home replaces "auto-select first collection" as the landing state - new toolbar Home button, Home pill in the breadcrumb, and the sidebar no longer picks a collection for you on launch. Home page: - Pinned docs as a card grid (pins.list -> per-document fetch, since the speculative pins.list response only carries pin records, not documents - N+1 is acceptable here since pins are a small curated set, unlike a full collection tree) - Recently Viewed (documents.viewed), Recently Updated and Created by Me (documents.list with sort/direction/userId - new richer DocumentsListRequest alongside the existing simple listDocuments, left untouched for its callers), and Popular (best-effort sort: "viewCount" - no confirmed popularity key in the vendored spec, worth eyeballing against a real server) - New Document button in Home's own toolbar New Document is now one shared dialog (NewDocumentSheet, mirrors MoveDocumentSheet's collection+parent picker) instead of three separate call sites that silently created "Untitled" instantly: sidebar collection's New Document, sidebar document's New Document, and the reader's toolbar button + menu item all open it now, pre-filled with whatever context they were opened from. OutlineKit: documents.viewed, richer documents.list filtering, with test coverage.
170 lines
6.2 KiB
Swift
170 lines
6.2 KiB
Swift
#if os(macOS)
|
|
import SwiftUI
|
|
import OutlineKit
|
|
|
|
/// The one shared "create a document" dialog — every "New Document" entry
|
|
/// point (sidebar collection, sidebar document, reader toolbar/menu, Home)
|
|
/// opens this instead of silently creating an "Untitled" document. Mirrors
|
|
/// `MoveDocumentSheet`'s collection+parent picker pattern.
|
|
@MainActor
|
|
struct NewDocumentSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
let apiClient: OutlineAPIClient
|
|
/// Pre-selected collection — e.g. opened from a specific collection's
|
|
/// "New Document". `nil` when opened from Home, where nothing is
|
|
/// pre-selected and the user must choose.
|
|
let initialCollectionID: String?
|
|
/// Pre-selected parent — e.g. opened from a document's "New Document",
|
|
/// which creates a child of that document.
|
|
let initialParentDocument: OutlineDocument?
|
|
let onCreated: (OutlineDocument) -> Void
|
|
|
|
@State private var title = ""
|
|
@State private var collections: [OutlineCollection] = []
|
|
@State private var selectedCollectionID: String?
|
|
@State private var rootDocuments: [OutlineDocument] = []
|
|
@State private var selectedParentID: String?
|
|
@State private var isLoadingCollections = false
|
|
@State private var isLoadingDestinationDocuments = false
|
|
@State private var isCreating = false
|
|
@State private var errorMessage: String?
|
|
@FocusState private var isTitleFocused: Bool
|
|
|
|
init(
|
|
apiClient: OutlineAPIClient,
|
|
initialCollectionID: String? = nil,
|
|
initialParentDocument: OutlineDocument? = nil,
|
|
onCreated: @escaping (OutlineDocument) -> Void
|
|
) {
|
|
self.apiClient = apiClient
|
|
self.initialCollectionID = initialCollectionID
|
|
self.initialParentDocument = initialParentDocument
|
|
self.onCreated = onCreated
|
|
}
|
|
|
|
/// `rootDocuments` is only root-level (mirroring `MoveDocumentSheet`'s
|
|
/// intentionally shallow picker) — if the initial parent is nested
|
|
/// deeper than that, it wouldn't otherwise appear as a selectable option
|
|
/// even though it's already the selection.
|
|
private var parentOptions: [OutlineDocument] {
|
|
var options = rootDocuments
|
|
if let initialParentDocument,
|
|
initialParentDocument.collectionId == selectedCollectionID,
|
|
!options.contains(where: { $0.id == initialParentDocument.id }) {
|
|
options.insert(initialParentDocument, at: 0)
|
|
}
|
|
return options
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("New Document")
|
|
.font(.headline)
|
|
|
|
TextField("Title", text: $title)
|
|
.textFieldStyle(.roundedBorder)
|
|
.focused($isTitleFocused)
|
|
.onSubmit { Task { await create() } }
|
|
|
|
if isLoadingCollections {
|
|
ProgressView().frame(maxWidth: .infinity)
|
|
} else {
|
|
Picker("Collection", selection: $selectedCollectionID) {
|
|
Text("Choose a collection").tag(String?.none)
|
|
ForEach(collections) { collection in
|
|
Text(collection.name).tag(Optional(collection.id))
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
|
|
Picker("Location", selection: $selectedParentID) {
|
|
Text("Collection root").tag(String?.none)
|
|
ForEach(parentOptions) { candidate in
|
|
Text(candidate.title.isEmpty ? "Untitled" : candidate.title).tag(Optional(candidate.id))
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
|
|
}
|
|
|
|
if let errorMessage {
|
|
Text(errorMessage)
|
|
.font(.callout)
|
|
.foregroundStyle(.red)
|
|
}
|
|
|
|
HStack {
|
|
Spacer()
|
|
Button("Cancel", role: .cancel) { dismiss() }
|
|
Button("Create") {
|
|
Task { await create() }
|
|
}
|
|
.keyboardShortcut(.defaultAction)
|
|
.disabled(selectedCollectionID == nil || isCreating)
|
|
}
|
|
}
|
|
.padding(20)
|
|
.frame(width: 380)
|
|
.task {
|
|
await loadCollections()
|
|
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId ?? collections.first?.id
|
|
selectedParentID = initialParentDocument?.id
|
|
isTitleFocused = true
|
|
}
|
|
.task(id: selectedCollectionID) {
|
|
await loadRootDocuments()
|
|
}
|
|
}
|
|
|
|
private func loadCollections() async {
|
|
isLoadingCollections = true
|
|
defer { isLoadingCollections = false }
|
|
do {
|
|
collections = try await apiClient.listCollections(offset: 0, limit: 100)
|
|
} catch {
|
|
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load collections.")
|
|
}
|
|
}
|
|
|
|
private func loadRootDocuments() async {
|
|
guard let selectedCollectionID else {
|
|
rootDocuments = []
|
|
return
|
|
}
|
|
isLoadingDestinationDocuments = true
|
|
defer { isLoadingDestinationDocuments = false }
|
|
do {
|
|
rootDocuments = try await apiClient.listDocuments(
|
|
collectionId: selectedCollectionID,
|
|
parentDocumentId: nil,
|
|
offset: 0,
|
|
limit: 100
|
|
)
|
|
} catch {
|
|
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load destination documents.")
|
|
}
|
|
}
|
|
|
|
private func create() async {
|
|
guard let selectedCollectionID else { return }
|
|
isCreating = true
|
|
defer { isCreating = false }
|
|
do {
|
|
let document = try await apiClient.createDocument(
|
|
CreateDocumentRequest(
|
|
title: title.isEmpty ? "Untitled" : title,
|
|
text: "",
|
|
collectionId: selectedCollectionID,
|
|
parentDocumentId: selectedParentID
|
|
)
|
|
)
|
|
onCreated(document)
|
|
dismiss()
|
|
} catch {
|
|
errorMessage = outlineErrorMessage(error, fallback: "Couldn't create this document.")
|
|
}
|
|
}
|
|
}
|
|
#endif
|