Files
Outpost/Outpost/Features/Account/AccountFooter.swift
T
Puranjay Savar Mattas ef085f0018 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.
2026-08-14 00:24:30 +01:00

123 lines
4.3 KiB
Swift

#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