import SwiftUI import UniformTypeIdentifiers import AppKit struct MenuBarContentView: View { @EnvironmentObject var store: AppStore @State private var showingSettings = false var body: some View { Group { if store.isAuthenticated { if showingSettings { SettingsView(showingSettings: $showingSettings) } else { ComposeView(showingSettings: $showingSettings) } } else { SetupView() } } .frame(width: 320) .padding(16) } } // MARK: - Setup (API key entry) struct SetupView: View { @EnvironmentObject var store: AppStore @FocusState private var keyFieldFocused: Bool var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Connect Buffit for Mac to your Buffer Account") .font(.headline) Text("Paste your API key generated by Buffer") .font(.caption) Text("Buffer -> Settings -> API -> Personal Keys") .font(.caption) SecureField("Buffer API key", text: $store.apiKey) .textFieldStyle(.roundedBorder) .focused($keyFieldFocused) .onSubmit { store.connect() } if let error = store.loadError { Text(error) .font(.caption) .foregroundStyle(.red) } HStack(alignment: .firstTextBaseline) { Text("Your API key is stored on this device, no one can see it.") .font(.caption) .foregroundStyle(.secondary) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) .layoutPriority(2) Spacer(minLength: 16) Button { store.connect() } label: { if store.isValidatingKey { ProgressView() .controlSize(.small) .frame(width: 55) } else { HStack(spacing: 6) { Text("Connect") .frame(width: 55) Image(systemName: "key.fill") } } } .layoutPriority(1) .keyboardShortcut(.defaultAction) .disabled(store.apiKey.trimmingCharacters(in: .whitespaces).isEmpty || store.isValidatingKey) } Divider() Button("Quit Buffit for Mac") { NSApplication.shared.terminate(nil) } .buttonStyle(.plain) .foregroundStyle(.secondary) .font(.caption) } .onAppear { keyFieldFocused = true } } } // MARK: - Settings struct SettingsView: View { @EnvironmentObject var store: AppStore @Binding var showingSettings: Bool var body: some View { VStack(alignment: .leading, spacing: 14) { HStack { Button { showingSettings = false } label: { Image(systemName: "chevron.left") } .buttonStyle(.plain) Text("Settings") .font(.headline) Spacer() } VStack(alignment: .leading, spacing: 6) { Text("Default for new posts") .font(.subheadline) .fontWeight(.medium) Picker("", selection: $store.defaultMode) { ForEach(DefaultPostMode.allCases) { mode in Text(mode.label).tag(mode) } } .pickerStyle(.radioGroup) .labelsHidden() Text("If a channel's queue is full (10 posts on the free plan), that post is saved as a draft automatically, even when the default above is set to queue.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Divider() Link("Support and website", destination: URL(string: "https://danielmorrisey.com/buffit")!) Link("Privacy policy", destination: URL(string: "https://danielmorrisey.com/buffit/privacy")!) Divider() HStack(spacing: 6) { Image(systemName: "hand.raised.fill") Text("How your data is handled") } Text("Posts and your Buffer API key are sent directly to Buffer. If you attach an image, it is uploaded to Buffit's media service so Buffer can retrieve it when publishing. Your API key stays in your Mac's Keychain.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Divider() Button(action: { store.disconnect() }) { HStack(spacing: 6) { Image(systemName: "rectangle.portrait.and.arrow.right") Text("Sign Out") } } .buttonStyle(.plain) .foregroundStyle(.secondary) .font(.caption) Button(action: { NSApplication.shared.terminate(nil) }) { HStack(spacing: 6) { Image(systemName: "power") Text("Quit Buffit") } } .buttonStyle(.plain) .foregroundStyle(.secondary) .font(.caption) } } } // MARK: - Compose (write + queue) struct ComposeView: View { @EnvironmentObject var store: AppStore @Binding var showingSettings: Bool @FocusState private var textFocused: Bool var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { Text("Compose Post to add to Queue") .font(.headline) Spacer() Menu { ForEach(store.channels) { channel in Button { store.toggleChannel(channel.id) } label: { HStack { Text(channel.name) if store.selectedChannelIds.contains(channel.id) { Image(systemName: "checkmark") } } } } Divider() Button("Select All") { store.selectAllChannels() } } label: { Image(systemName: "list.bullet") } .menuStyle(.borderlessButton) .fixedSize() Button { showingSettings = true } label: { Image(systemName: "gearshape") } .buttonStyle(.plain) Group { if store.isRefreshingChannels { ProgressView() .controlSize(.small) .frame(width: 24, height: 24) } else { Button { Task { await store.refreshChannels() } } label: { Image(systemName: "arrow.clockwise") } .buttonStyle(.plain) .disabled(store.isRefreshingChannels) } } .fixedSize() } ZStack(alignment: .topLeading) { if store.postText.isEmpty { Text("Start typing here...") .foregroundStyle(.tertiary) .padding(.top, 8) .padding(.leading, 5) } TextEditor(text: $store.postText) .frame(height: 90) .font(.system(size: 13)) .scrollContentBackground(.hidden) .focused($textFocused) } .padding(4) .background(RoundedRectangle(cornerRadius: 6).fill(Color(nsColor: .textBackgroundColor))) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.25))) if let image = store.attachedImage { HStack(spacing: 12) { Image(systemName: "photo.fill") .font(.title3) .foregroundStyle(.blue) VStack(alignment: .leading, spacing: 2) { Text("Image attached") .font(.caption) .fontWeight(.medium) Text("\(image.size) bytes") .font(.caption2) .foregroundStyle(.secondary) } Spacer() Button { store.removeAttachedImage() } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } .buttonStyle(.plain) } .padding(8) .background(Color.blue.opacity(0.1)) .cornerRadius(6) } else { Button { let panel = NSOpenPanel() panel.allowedContentTypes = [.image] panel.allowsMultipleSelection = false panel.canChooseFiles = true panel.canChooseDirectories = false if panel.runModal() == .OK, let url = panel.url { store.attachImage(fileURL: url) } } label: { HStack(spacing: 6) { Image(systemName: "photo.badge.plus") Text("Upload Image") } .font(.caption) } .buttonStyle(.bordered) .frame(maxWidth: .infinity, alignment: .leading) .disabled(store.isUploadingImage) } if let error = store.imageUploadError { Text(error) .font(.caption) .foregroundStyle(.red) } ChannelChipRow() statusView HStack { Text("\(store.postText.count) characters") .font(.caption2) .foregroundStyle(.secondary) Spacer() Button { store.addToQueue() } label: { if store.isPosting { ProgressView().controlSize(.small) } else { Text(store.defaultMode == .queue ? "Add to Queue" : "Save as Draft") } } .keyboardShortcut(.defaultAction) .keyboardShortcut(.return, modifiers: [.command]) .disabled(!store.canPost) } } .onAppear { textFocused = true } } @ViewBuilder var statusView: some View { switch store.status { case .idle, .posting: EmptyView() case .done(let queued, let drafted, let failed): Text(summary(queued: queued, drafted: drafted, failed: failed)) .font(.caption) .foregroundStyle(failed == 0 ? .green : .orange) case .error(let message): Text(message) .font(.caption) .foregroundStyle(.red) } } private func summary(queued: Int, drafted: Int, failed: Int) -> String { var parts: [String] = [] if queued > 0 { parts.append("\(queued) queued") } if drafted > 0 { parts.append("\(drafted) drafted") } if failed > 0 { parts.append("\(failed) failed") } return parts.joined(separator: ", ") + "." } } // MARK: - Channel selection chips struct ChannelChipRow: View { @EnvironmentObject var store: AppStore var body: some View { if store.isRefreshingChannels { ProgressView() .frame(maxWidth: .infinity) .padding(.vertical, 6) } else if store.channels.isEmpty { Text("No connected channels found on this Buffer account.") .font(.caption) .foregroundStyle(.secondary) } else { FlowLayout(spacing: 6) { ForEach(store.channels) { channel in let selected = store.selectedChannelIds.contains(channel.id) Button { store.toggleChannel(channel.id) } label: { Text(channel.name) .font(.caption) .padding(.horizontal, 8) .padding(.vertical, 4) .background( Capsule().fill(selected ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.12)) ) .overlay( Capsule().stroke(selected ? Color.accentColor : Color.clear, lineWidth: 1) ) .foregroundStyle(selected ? Color.accentColor : Color.secondary) } .buttonStyle(.plain) } } } } } /// Simple wrapping layout for the channel chips. struct FlowLayout: Layout { var spacing: CGFloat = 6 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let width = proposal.width ?? 300 var x: CGFloat = 0 var y: CGFloat = 0 var rowHeight: CGFloat = 0 for subview in subviews { let size = subview.sizeThatFits(.unspecified) if x + size.width > width, x > 0 { x = 0 y += rowHeight + spacing rowHeight = 0 } x += size.width + spacing rowHeight = max(rowHeight, size.height) } return CGSize(width: width, height: y + rowHeight) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { var x: CGFloat = bounds.minX var y: CGFloat = bounds.minY var rowHeight: CGFloat = 0 for subview in subviews { let size = subview.sizeThatFits(.unspecified) if x + size.width > bounds.maxX, x > bounds.minX { x = bounds.minX y += rowHeight + spacing rowHeight = 0 } subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) x += size.width + spacing rowHeight = max(rowHeight, size.height) } } }