import AppKit import SwiftUI import UniformTypeIdentifiers struct PostGeneratorView: View { @Environment(\.requirePremiumAccess) private var requirePremiumAccess @Bindable var viewModel: PostGeneratorViewModel private enum Layout { static let horizontalPadding: CGFloat = 24 static let columnWidth: CGFloat = 300 static let columnSpacing: CGFloat = 20 static let sectionSpacing: CGFloat = 12 static let imageUploadHeight: CGFloat = 140 } var body: some View { VStack(spacing: 0) { header messageBanners ScrollView { VStack(alignment: .leading, spacing: 12) { tabBar PostPageBoundary { switch viewModel.selectedTab { case .compose: composeContent case .preview: previewContent } } } .padding(.horizontal, Layout.horizontalPadding) .padding(.top, 12) .padding(.bottom, 24) } } .background(AppTheme.background) .fileImporter( isPresented: $viewModel.showImageImporter, allowedContentTypes: [.image], allowsMultipleSelection: false ) { result in switch result { case .success(let urls): if let url = urls.first { viewModel.setImage(from: url) } case .failure(let error): viewModel.handleImageImportFailure(error) } } .alert("Replace existing content?", isPresented: $viewModel.showOverwriteConfirmation) { Button("Cancel", role: .cancel) { viewModel.cancelOverwriteConfirmation() } Button("Replace", role: .destructive) { guard !viewModel.usesLiveAI || requirePremiumAccess() else { return } Task { await viewModel.confirmOverwriteAndGenerate() } } } message: { Text("Generating will replace your current title, body, and poll options.") } .onChange(of: viewModel.draft.topic) { _, _ in viewModel.notifyDraftEdited() } .onChange(of: viewModel.draft.subreddit) { _, _ in viewModel.notifyDraftEdited() } .onChange(of: viewModel.draft.title) { _, _ in viewModel.notifyDraftEdited() } .onChange(of: viewModel.draft.body) { _, _ in viewModel.notifyDraftEdited() } .onChange(of: viewModel.draft.linkURL) { _, _ in viewModel.notifyDraftEdited() } .onChange(of: viewModel.draft.videoURL) { _, _ in viewModel.notifyDraftEdited() } .onChange(of: viewModel.draft.flair) { _, _ in viewModel.notifyDraftEdited() } } // MARK: - Header private var header: some View { VStack(spacing: 0) { HStack(spacing: 14) { ModernSidebarIconView(kind: .postGenerator, size: 40) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text("Post Generator") .font(.system(size: 18, weight: .semibold)) .foregroundStyle(AppTheme.textPrimary) if viewModel.hasDraftChanges { Circle() .fill(AppTheme.accentOrange) .frame(width: 6, height: 6) .help("Draft has unsaved edits") } } Text(viewModel.usesLiveAI ? "Create Reddit-ready posts with AI" : "Create Reddit-ready posts with AI templates") .font(.system(size: 11)) .foregroundStyle(AppTheme.textSecondary) } Spacer() headerActions } .padding(.horizontal, Layout.horizontalPadding) .padding(.vertical, 16) FullWidthDivider() } } @ViewBuilder private var messageBanners: some View { if let error = viewModel.errorMessage { PostGeneratorMessageBanner(message: error, isError: true) .padding(.horizontal, Layout.horizontalPadding) .padding(.top, 8) } else if let success = viewModel.successMessage { PostGeneratorMessageBanner(message: success, isError: false) .padding(.horizontal, Layout.horizontalPadding) .padding(.top, 8) } } private var headerActions: some View { HStack(spacing: 8) { Button { viewModel.resetDraft() } label: { Label("Reset", systemImage: "arrow.counterclockwise") .font(.system(size: 11, weight: .medium)) } .buttonStyle(AppSecondaryButtonStyle()) Button { viewModel.copyToClipboard() } label: { Label("Copy", systemImage: "doc.on.doc") .font(.system(size: 11, weight: .medium)) } .buttonStyle(AppSecondaryButtonStyle()) .disabled(!viewModel.canExport) Button { guard !viewModel.usesLiveAI || requirePremiumAccess() else { return } Task { await viewModel.generatePost() } } label: { HStack(spacing: 6) { if viewModel.isGenerating { ProgressView() .controlSize(.small) .tint(.white) } else { Image(systemName: "sparkles") .font(.system(size: 11, weight: .semibold)) } Text(viewModel.isGenerating ? "Generating…" : "Generate") .font(.system(size: 11, weight: .semibold)) } .foregroundStyle(.white) .padding(.horizontal, 14) .padding(.vertical, 8) .background(viewModel.canGenerate ? AppTheme.accentPurple : AppTheme.accentPurple.opacity(0.4)) .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(AppPrimaryButtonStyle()) .disabled(!viewModel.canGenerate) } } // MARK: - Tab Bar private var tabBar: some View { ToolSegmentedTabBar( selection: $viewModel.selectedTab, accentColor: AppTheme.accentPurple, width: 220 ) } // MARK: - Compose private var composeContent: some View { composeColumns } private var composeColumns: some View { HStack(alignment: .top, spacing: Layout.columnSpacing) { composeColumn(title: "Configuration", icon: "slider.horizontal.3") { configurationPanel } .frame(width: Layout.columnWidth) .layoutPriority(1) Rectangle() .fill(AppTheme.border) .frame(width: 1) .padding(.vertical, 2) composeColumn(title: "Post Content", icon: "doc.text") { editorPanel } .frame(minWidth: 0, maxWidth: .infinity) .layoutPriority(0) } } private func composeColumn( title: String, icon: String, @ViewBuilder content: () -> Content ) -> some View { VStack(alignment: .leading, spacing: Layout.sectionSpacing) { composeSectionHeader(title: title, icon: icon) content() } } private func composeSectionHeader(title: String, icon: String) -> some View { HStack(spacing: 6) { Image(systemName: icon) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(AppTheme.accentPurpleLight) Text(title) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(AppTheme.textSecondary) } .textCase(.uppercase) .tracking(0.4) } private var configurationPanel: some View { VStack(spacing: Layout.sectionSpacing) { PostFormCard(title: "Post Type", subtitle: "Choose how you want to post") { PostTypePicker(selectedType: Binding( get: { viewModel.draft.postType }, set: { viewModel.selectPostType($0) } )) } PostFormCard(title: "Target Subreddit", subtitle: "Where will this be posted?") { VStack(alignment: .leading, spacing: 6) { PostFormField( label: "Subreddit", placeholder: "technology", text: $viewModel.draft.subreddit, prefix: "r/" ) if !viewModel.draft.subreddit.isEmpty, !PostDraftValidator.isValidSubreddit(viewModel.draft.subreddit) { validationHint("Use 3–21 characters: letters, numbers, underscores only.") } } } PostFormCard(title: "AI Settings", subtitle: "Guide the tone and topic") { VStack(alignment: .leading, spacing: 12) { PostFormField( label: "Topic / Keywords", placeholder: "e.g. macOS productivity tips", text: $viewModel.draft.topic ) VStack(alignment: .leading, spacing: 6) { Text("Tone") .font(.system(size: 10, weight: .medium)) .foregroundStyle(AppTheme.textTertiary) TonePicker(selectedTone: $viewModel.draft.tone) } } } PostFormCard(title: "Labels", subtitle: "Tags and optional flair") { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { PostTagToggle( title: "NSFW", systemImage: "exclamationmark.triangle.fill", activeColor: Color(hex: 0xEF4444), isOn: $viewModel.draft.isNSFW ) .frame(maxWidth: .infinity) PostTagToggle( title: "Spoiler", systemImage: "eye.slash.fill", activeColor: AppTheme.textSecondary, isOn: $viewModel.draft.isSpoiler ) .frame(maxWidth: .infinity) PostTagToggle( title: "OC", systemImage: "star.fill", activeColor: AppTheme.accentGreen, isOn: $viewModel.draft.isOC ) .frame(maxWidth: .infinity) } PostFormField( label: "Flair", placeholder: "Discussion, Question, Meme…", text: $viewModel.draft.flair ) } } } } private var editorPanel: some View { VStack(spacing: Layout.sectionSpacing) { PostFormCard(title: "Title", subtitle: "Required for all post types") { PostFormField( label: "Post Title", placeholder: "An engaging title for your post", text: $viewModel.draft.title ) } postTypeEditor .frame(minHeight: 260, alignment: .top) characterCountFooter } } @ViewBuilder private var postTypeEditor: some View { switch viewModel.draft.postType { case .text: textPostEditor case .image: imagePostEditor case .link: linkPostEditor case .video: videoPostEditor case .poll: pollPostEditor } } private var textPostEditor: some View { PostFormCard(title: "Body", subtitle: "Markdown supported — **bold**, *italic*, links") { PostFormTextEditor( label: "Post Body", placeholder: "Write your post content here…", text: $viewModel.draft.body, minHeight: 220 ) } } private var imagePostEditor: some View { PostFormCard(title: "Image", subtitle: "Upload an image for your post") { VStack(alignment: .leading, spacing: 12) { imageUploadArea PostFormTextEditor( label: "Caption (optional)", placeholder: "Add context for your image…", text: $viewModel.draft.body, minHeight: 80 ) } } } private var linkPostEditor: some View { PostFormCard(title: "Link", subtitle: "Share a URL with the community") { VStack(alignment: .leading, spacing: 12) { PostFormField( label: "URL", placeholder: "https://example.com/article", text: $viewModel.draft.linkURL ) if !viewModel.draft.linkURL.isEmpty, !PostDraftValidator.isValidHTTPURL(viewModel.draft.linkURL) { validationHint("Enter a valid http or https URL.") } PostFormTextEditor( label: "Description (optional)", placeholder: "Why are you sharing this link?", text: $viewModel.draft.body, minHeight: 100 ) } } } private var videoPostEditor: some View { PostFormCard(title: "Video", subtitle: "Link to YouTube, Vimeo, or direct video URL") { VStack(alignment: .leading, spacing: 12) { PostFormField( label: "Video URL", placeholder: "https://youtube.com/watch?v=…", text: $viewModel.draft.videoURL ) if !viewModel.draft.videoURL.isEmpty, !PostDraftValidator.isValidHTTPURL(viewModel.draft.videoURL) { validationHint("Enter a valid http or https URL.") } PostFormTextEditor( label: "Description (optional)", placeholder: "Add context for your video…", text: $viewModel.draft.body, minHeight: 100 ) } } } private var pollPostEditor: some View { PostFormCard(title: "Poll", subtitle: "2–6 options, community votes") { VStack(alignment: .leading, spacing: 12) { ForEach(Array(viewModel.draft.pollOptions.enumerated()), id: \.element.id) { index, option in HStack(spacing: 8) { PostFormField( label: "Option \(index + 1)", placeholder: "Enter poll option", text: Binding( get: { option.text }, set: { viewModel.updatePollOption(id: option.id, text: $0) } ) ) if viewModel.canRemovePollOption { Button { viewModel.removePollOption(option) } label: { Image(systemName: "minus.circle.fill") .font(.system(size: 14)) .foregroundStyle(Color(hex: 0xEF4444).opacity(0.8)) } .buttonStyle(AppPlainButtonStyle()) .hoverOverlay(cornerRadius: 7) .padding(.top, 18) } } } if viewModel.canAddPollOption { Button { viewModel.addPollOption() } label: { Label("Add Option", systemImage: "plus.circle.fill") .font(.system(size: 11, weight: .medium)) .foregroundStyle(AppTheme.accentPurpleLight) } .buttonStyle(AppPlainButtonStyle()) .hoverOverlay(cornerRadius: 6) } VStack(alignment: .leading, spacing: 6) { Text("Poll Duration") .font(.system(size: 10, weight: .medium)) .foregroundStyle(AppTheme.textTertiary) HStack(spacing: 8) { ForEach(PollDuration.allCases) { duration in Button { viewModel.draft.pollDuration = duration viewModel.notifyDraftEdited() } label: { Text(duration.label) .font(.system(size: 10, weight: .semibold)) .foregroundStyle( viewModel.draft.pollDuration == duration ? .white : AppTheme.textSecondary ) .frame(maxWidth: .infinity) .padding(.vertical, 7) .background( RoundedRectangle(cornerRadius: 7) .fill( viewModel.draft.pollDuration == duration ? AppTheme.accentPurple : AppTheme.panelBackground ) ) .overlay( RoundedRectangle(cornerRadius: 7) .stroke(AppTheme.cardBorder, lineWidth: 1) ) } .buttonStyle(AppPlainButtonStyle()) .hoverOverlay(cornerRadius: 7) } } } PostFormTextEditor( label: "Context (optional)", placeholder: "Explain why you're running this poll…", text: $viewModel.draft.body, minHeight: 80 ) } } } @ViewBuilder private var imageUploadArea: some View { Group { if let url = viewModel.draft.imageFileURL, let nsImage = NSImage(contentsOf: url) { ZStack(alignment: .topTrailing) { Image(nsImage: nsImage) .resizable() .scaledToFit() .frame(maxWidth: .infinity, maxHeight: Layout.imageUploadHeight) .frame(maxWidth: .infinity, maxHeight: Layout.imageUploadHeight) .clipped() Button { viewModel.removeImage() } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 18)) .foregroundStyle(.white) .shadow(radius: 2) } .buttonStyle(AppPlainButtonStyle()) .hoverOverlay(cornerRadius: 8) .padding(8) } } else { Button { viewModel.showImageImporter = true } label: { VStack(spacing: 8) { Image(systemName: "photo.badge.plus") .font(.system(size: 24)) .foregroundStyle(AppTheme.accentPurpleLight) Text("Click to upload image") .font(.system(size: 11, weight: .medium)) .foregroundStyle(AppTheme.textSecondary) Text("PNG, JPG, GIF, WebP") .font(.system(size: 9)) .foregroundStyle(AppTheme.textTertiary) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppTheme.panelBackground) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .strokeBorder( AppTheme.accentPurple.opacity(0.3), style: StrokeStyle(lineWidth: 1, dash: [6, 4]) ) ) } .buttonStyle(AppPlainButtonStyle()) .hoverCard(cornerRadius: 8) } } .frame(maxWidth: .infinity) .frame(height: Layout.imageUploadHeight) .clipShape(RoundedRectangle(cornerRadius: 8)) } private var characterCountFooter: some View { HStack(spacing: 12) { Text(titleCharacterCount) .font(.system(size: 10, weight: .medium, design: .monospaced)) .foregroundStyle( viewModel.draft.title.count > PostDraftValidator.maxTitleLength ? Color(hex: 0xF87171) : AppTheme.textTertiary ) if !viewModel.draft.body.isEmpty { Text("·") .foregroundStyle(AppTheme.textTertiary) Text(bodyCharacterCount) .font(.system(size: 10, weight: .medium, design: .monospaced)) .foregroundStyle( viewModel.draft.body.count > PostDraftValidator.maxBodyLength ? Color(hex: 0xF87171) : AppTheme.textTertiary ) } Spacer() Text("Reddit limits: 300 title · 40,000 body") .font(.system(size: 10)) .foregroundStyle(AppTheme.textTertiary) } .padding(.horizontal, 12) .padding(.vertical, 9) .background( RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall) .fill(AppTheme.cardBackground) .overlay( RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall) .stroke(AppTheme.cardBorder, lineWidth: 1) ) ) } private var titleCharacterCount: String { "\(viewModel.draft.title.count) / \(PostDraftValidator.maxTitleLength) title" } private var bodyCharacterCount: String { "\(viewModel.draft.body.count) / \(PostDraftValidator.maxBodyLength) body" } // MARK: - Preview private var previewContent: some View { VStack(alignment: .leading, spacing: Layout.columnSpacing) { composeSectionHeader(title: "Live Preview", icon: "eye") HStack { Spacer(minLength: 0) VStack(spacing: Layout.sectionSpacing) { RedditPostPreviewCard( draft: viewModel.draft, formattedSubreddit: viewModel.formattedSubreddit ) previewMetadata } .frame(maxWidth: 560) Spacer(minLength: 0) } } } private var previewMetadata: some View { VStack(alignment: .leading, spacing: 0) { metadataRow(label: "Type", value: viewModel.draft.postType.title) metadataDivider metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit) metadataDivider metadataRow(label: "Tone", value: viewModel.draft.tone.title) metadataDivider metadataRow(label: "Engine", value: viewModel.usesLiveAI ? "AI" : "Local templates") if viewModel.draft.isNSFW || viewModel.draft.isSpoiler || viewModel.draft.isOC { metadataDivider metadataRow( label: "Tags", value: [ viewModel.draft.isNSFW ? "NSFW" : nil, viewModel.draft.isSpoiler ? "Spoiler" : nil, viewModel.draft.isOC ? "OC" : nil, ] .compactMap { $0 } .joined(separator: ", ") ) } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall) .fill(AppTheme.cardBackground) .overlay( RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall) .stroke(AppTheme.cardBorder, lineWidth: 1) ) ) } private var metadataDivider: some View { Rectangle() .fill(AppTheme.border) .frame(height: 1) .padding(.vertical, 6) } private func metadataRow(label: String, value: String) -> some View { HStack(alignment: .top, spacing: 12) { Text(label) .font(.system(size: 10, weight: .medium)) .foregroundStyle(AppTheme.textTertiary) .frame(width: 72, alignment: .leading) Text(value) .font(.system(size: 10)) .foregroundStyle(AppTheme.textSecondary) .fixedSize(horizontal: false, vertical: true) } } private func validationHint(_ message: String) -> some View { HStack(spacing: 4) { Image(systemName: "exclamationmark.circle.fill") .font(.system(size: 9)) Text(message) .font(.system(size: 9)) } .foregroundStyle(Color(hex: 0xF87171)) } } private struct PostSecondaryButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { AppSecondaryButtonStyle().makeBody(configuration: configuration) } } #Preview { PostGeneratorView(viewModel: PostGeneratorViewModel()) .frame(width: 880, height: 720) }