Files
Outpost/Outpost/Features/Collections/NewDocumentSheet.swift
T
Puranjay Savar Mattas c3083bf0c0 feat: Home page + universal New Document dialog
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.
2026-08-14 16:03:17 +01:00

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