fix(home): distinct pinned card style, matching tab bar, drop broken sort
- Pinned section uses a new dense PinnedDocumentCard (single-line,
explicit pin glyph) instead of the same tall card as the tab grids -
it's meant for a quick scan of a small curated set, not browsing,
and needed to actually show a pin so pinned docs are recognizable
at a glance.
- Tab bar now matches CollectionOverviewView.tabBar's exact style
instead of the native segmented picker.
- Popular tab's sort: "viewCount" was a guess and the server rejected
it outright ("sort: Invalid input") - sort is validated against a
fixed set server-side, not free-form like the vendored spec's typing
implies. Falls back to default order now, same conclusion already
reached for CollectionTab.popular - no real popularity ranking is
exposed via the REST API.
- Layout: pinned section now claims roughly the top half of the page
(scrolling within itself if there are more pinned docs than fit)
when there's anything pinned, collapsing away entirely otherwise so
the tabs get full height.
This commit is contained in:
@@ -10,7 +10,8 @@ struct HomeView: View {
|
||||
@State private var selectedTab: HomeTab = .recentlyViewed
|
||||
@State private var isShowingNewDocumentSheet = false
|
||||
|
||||
private let gridColumns = [GridItem(.adaptive(minimum: 220), spacing: 12)]
|
||||
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) {
|
||||
self.apiClient = apiClient
|
||||
@@ -18,16 +19,25 @@ struct HomeView: View {
|
||||
_viewModel = State(initialValue: HomeViewModel(apiClient: apiClient))
|
||||
}
|
||||
|
||||
private var isShowingPinnedSection: Bool {
|
||||
viewModel.isLoadingPinned || !viewModel.pinnedDocuments.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
// 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)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
@@ -48,64 +58,64 @@ struct HomeView: View {
|
||||
.task(id: selectedTab) { await viewModel.load(tab: selectedTab) }
|
||||
}
|
||||
|
||||
private var isShowingPinnedSection: Bool {
|
||||
viewModel.isLoadingPinned || !viewModel.pinnedDocuments.isEmpty
|
||||
}
|
||||
|
||||
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)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
LazyVGrid(columns: gridColumns, spacing: 12) {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: pinnedGridColumns, spacing: 8) {
|
||||
ForEach(viewModel.pinnedDocuments) { document in
|
||||
Button {
|
||||
onOpenDocument(document)
|
||||
} label: {
|
||||
DocumentCardView(document: document)
|
||||
PinnedDocumentCard(document: document)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var tabSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Picker("", selection: $selectedTab) {
|
||||
ForEach(HomeTab.allCases) { tab in
|
||||
Text(tab.rawValue).tag(tab)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 520)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
tabBar
|
||||
|
||||
Divider()
|
||||
|
||||
let documents = viewModel.documents(for: selectedTab)
|
||||
|
||||
Group {
|
||||
if viewModel.isLoadingTab && documents.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 40)
|
||||
.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 {
|
||||
LazyVGrid(columns: gridColumns, spacing: 12) {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: tabGridColumns, spacing: 12) {
|
||||
ForEach(documents) { document in
|
||||
Button {
|
||||
onOpenDocument(document)
|
||||
@@ -115,8 +125,39 @@ struct HomeView: View {
|
||||
.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
|
||||
|
||||
@@ -61,15 +61,14 @@ final class HomeViewModel {
|
||||
case .recentlyViewed:
|
||||
recentlyViewed = try await apiClient.listViewedDocuments(offset: 0, limit: 25)
|
||||
case .popular:
|
||||
// Best-effort: the vendored spec's `Sorting.sort` is a
|
||||
// free-form string, not an enum, with no documented
|
||||
// popularity key. If the server doesn't recognize
|
||||
// "viewCount" it most likely just falls back to a default
|
||||
// order rather than erroring - worth eyeballing against a
|
||||
// real server.
|
||||
popular = try await apiClient.documentsList(
|
||||
DocumentsListRequest(sort: "viewCount", direction: "DESC", limit: 25)
|
||||
)
|
||||
// `sort: "viewCount"` was a guess and the server rejected it
|
||||
// outright ("sort: Invalid input") — sort is validated
|
||||
// server-side against a fixed set, not free-form like the
|
||||
// vendored spec's typing implies. Same conclusion as
|
||||
// `CollectionTab.popular`: there's no real popularity
|
||||
// ranking exposed via the REST API, so this falls back to
|
||||
// the default list order rather than guessing again.
|
||||
popular = try await apiClient.documentsList(DocumentsListRequest(limit: 25))
|
||||
case .recentlyUpdated:
|
||||
recentlyUpdated = try await apiClient.documentsList(
|
||||
DocumentsListRequest(sort: "updatedAt", direction: "DESC", limit: 25)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Deliberately distinct from `DocumentCardView` — the pinned section is for
|
||||
/// a quick scan of a small curated set, not browsing, so this is a dense
|
||||
/// single-line row rather than a tall card, with an explicit pin glyph so
|
||||
/// it doesn't read the same as the tab grids below it.
|
||||
struct PinnedDocumentCard: View {
|
||||
@Environment(StarStore.self) private var starStore
|
||||
let document: OutlineDocument
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
if let emoji = document.emoji {
|
||||
Text(emoji)
|
||||
.font(.title3)
|
||||
} else {
|
||||
Image(systemName: "doc.text")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(document.title.isEmpty ? "Untitled" : document.title)
|
||||
.font(.callout.weight(.medium))
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if starStore.isStarred(documentId: document.id) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
|
||||
Image(systemName: "pin.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user