feat(account): add account menu, about panel, and preferences (macOS)

Sidebar footer opens a popover (not Menu — its AppKit-bridged label
rendering broke on click with an avatar image in it) with profile,
keyboard shortcuts, docs/feedback links, and log out. About panel and
a minimal Preferences window round out the app-menu affordances.
This commit is contained in:
2026-08-14 00:24:30 +01:00
parent 654ec94b80
commit ef085f0018
6 changed files with 327 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
#if os(macOS)
import SwiftUI
struct AboutView: View {
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
private var appName: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
}
private var versionString: String {
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
return "Version \(shortVersion) (\(buildNumber))"
}
private var copyrightYear: String {
String(Calendar.current.component(.year, from: Date()))
}
var body: some View {
VStack(spacing: 16) {
if let appIcon = NSImage(named: "AppIcon") {
Image(nsImage: appIcon)
.resizable()
.frame(width: 80, height: 80)
} else {
Image(systemName: "text.book.closed.fill")
.font(.system(size: 48))
.foregroundStyle(Color.accentColor)
.frame(width: 80, height: 80)
}
VStack(spacing: 4) {
Text(appName)
.font(.title2.bold())
Text(versionString)
.font(.caption)
.foregroundStyle(.secondary)
}
Text("A native Apple client for self-hosted Outline, built for full editing parity with the web app.")
.font(.callout)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity)
VStack(spacing: 10) {
Link(destination: repositoryURL) {
Label("View Source on Git", systemImage: "link")
}
.font(.callout)
Button("Check for Updates…") {
checkForUpdates()
}
}
Text("© \(copyrightYear) Puranjay Savar Mattas")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding(32)
.frame(width: 320)
}
// Update checking isn't wired to a distribution channel yet button is a placeholder.
private func checkForUpdates() {}
}
#endif
@@ -0,0 +1,122 @@
#if os(macOS)
import SwiftUI
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
/// contains the avatar image reliably broke its own sizing on click (even after
/// several hardening attempts) that's specifically `Menu`'s AppKit-bridged
/// label rendering, which a `Button` doesn't go through.
struct AccountFooter: View {
@Environment(SessionStore.self) private var session
@Environment(\.openURL) private var openURL
@Environment(\.openWindow) private var openWindow
@Environment(\.openSettings) private var openSettings
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@State private var isMenuPresented = false
@State private var isShowingLogoutConfirmation = false
@State private var isShowingProfile = false
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
private let issuesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/issues")!
private var apiDocumentationURL: URL? {
session.serverURL?.appendingPathComponent("developers")
}
var body: some View {
Button {
isMenuPresented = true
} label: {
HStack(spacing: 10) {
AvatarBadge(avatarURL: session.userAvatarURL, size: 28, placeholderSystemImage: "person.crop.circle.fill")
VStack(alignment: .leading, spacing: 1) {
Text(session.userName ?? "Signed In")
.font(.subheadline.weight(.medium))
.lineLimit(1)
if let email = session.userEmail {
Text(email)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity)
.padding(.bottom, 16)
.popover(isPresented: $isMenuPresented, arrowEdge: .trailing) {
menuContent
}
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
.sheet(isPresented: $isShowingProfile) {
ProfileView()
}
}
private var menuContent: some View {
VStack(alignment: .leading, spacing: 2) {
menuItem("Keyboard Shortcuts…") { openWindow(id: "keyboard-shortcuts") }
Divider()
menuItem("Documentation") { openURL(repositoryURL) }
if let apiDocumentationURL {
menuItem("API Documentation") { openURL(apiDocumentationURL) }
}
menuItem("Changelog") { openURL(repositoryURL) }
Divider()
menuItem("Send Us Feedback") { openURL(issuesURL) }
menuItem("Report a Bug") { openURL(issuesURL) }
Divider()
VStack(alignment: .leading, spacing: 4) {
Text("Appearance")
.font(.caption)
.foregroundStyle(.secondary)
Picker("Appearance", selection: $appearance) {
ForEach(AppAppearance.allCases) { option in
Text(option.label).tag(option)
}
}
.pickerStyle(.segmented)
.labelsHidden()
}
.padding(.horizontal, 6)
.padding(.vertical, 4)
menuItem("Profile…") { isShowingProfile = true }
menuItem("Preferences…") { openSettings() }
Divider()
menuItem("Log Out", role: .destructive) { isShowingLogoutConfirmation = true }
}
.padding(8)
.frame(width: 240)
}
private func menuItem(_ title: String, role: ButtonRole? = nil, action: @escaping () -> Void) -> some View {
Button(role: role) {
isMenuPresented = false
action()
} label: {
Text(title)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.foregroundStyle(role == .destructive ? Color.red : Color.primary)
.padding(.vertical, 4)
.padding(.horizontal, 6)
.contentShape(Rectangle())
}
}
#endif
@@ -0,0 +1,39 @@
#if os(macOS)
import SwiftUI
struct KeyboardShortcutsView: View {
private struct Shortcut: Identifiable {
let id = UUID()
let action: String
let keys: String
}
private let shortcuts: [Shortcut] = [
Shortcut(action: "Sign In", keys: ""),
Shortcut(action: "Preferences", keys: "⌘ ,"),
Shortcut(action: "Close Window", keys: "⌘ W"),
Shortcut(action: "Quit Outpost", keys: "⌘ Q")
]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Keyboard Shortcuts")
.font(.title3.bold())
VStack(spacing: 10) {
ForEach(shortcuts) { shortcut in
HStack {
Text(shortcut.action)
Spacer()
Text(shortcut.keys)
.foregroundStyle(.secondary)
.monospaced()
}
}
}
}
.padding(24)
.frame(width: 280)
}
}
#endif
@@ -0,0 +1,40 @@
#if os(macOS)
import SwiftUI
struct PreferencesView: View {
@Environment(SessionStore.self) private var session
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@State private var isShowingLogoutConfirmation = false
var body: some View {
Form {
Section("Appearance") {
Picker("Appearance", selection: $appearance) {
ForEach(AppAppearance.allCases) { option in
Text(option.label).tag(option)
}
}
.pickerStyle(.segmented)
.labelsHidden()
}
Section("Account") {
LabeledContent("Signed in as", value: session.userName ?? "")
if let email = session.userEmail {
LabeledContent("Email", value: email)
}
if let teamName = session.teamName {
LabeledContent("Workspace", value: teamName)
}
Button("Log Out…", role: .destructive) {
isShowingLogoutConfirmation = true
}
}
}
.formStyle(.grouped)
.frame(width: 380, height: 300)
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
}
}
#endif
@@ -0,0 +1,37 @@
#if os(macOS)
import SwiftUI
struct ProfileView: View {
@Environment(SessionStore.self) private var session
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 16) {
AvatarBadge(avatarURL: session.userAvatarURL, size: 64, placeholderSystemImage: "person.crop.circle.fill")
VStack(spacing: 4) {
Text(session.userName ?? "Unknown")
.font(.title3.bold())
if let email = session.userEmail {
Text(email)
.font(.callout)
.foregroundStyle(.secondary)
}
}
if let teamName = session.teamName {
Label(teamName, systemImage: "building.2")
.font(.callout)
.foregroundStyle(.secondary)
}
Button("Done") {
dismiss()
}
.keyboardShortcut(.defaultAction)
}
.padding(32)
.frame(width: 300)
}
}
#endif
@@ -0,0 +1,18 @@
import SwiftUI
extension View {
func logoutConfirmationDialog(isPresented: Binding<Bool>, session: SessionStore) -> some View {
confirmationDialog(
"Log Out of Outpost?",
isPresented: isPresented,
titleVisibility: .visible
) {
Button("Log Out", role: .destructive) {
session.signOut()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("You'll need your server address and API token to sign in again.")
}
}
}