import AppKit import SwiftUI import UniformTypeIdentifiers // MARK: - Page Boundary struct PostPageBoundary: View { @ViewBuilder let content: Content var body: some View { content .padding(20) .background( RoundedRectangle(cornerRadius: AppTheme.cornerRadius) .fill(AppTheme.panelBackground) ) .overlay( RoundedRectangle(cornerRadius: AppTheme.cornerRadius) .stroke(AppTheme.cardBorder, lineWidth: 1) ) } } // MARK: - Shared Form Components struct PostFormCard: View { let title: String var subtitle: String? @ViewBuilder let content: Content var body: some View { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 2) { Text(title) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(AppTheme.textPrimary) if let subtitle { Text(subtitle) .font(.system(size: 10)) .foregroundStyle(AppTheme.textSecondary) } } content } .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) ) ) } } struct PostFormField: View { let label: String var placeholder: String = "" @Binding var text: String var prefix: String? var body: some View { VStack(alignment: .leading, spacing: 6) { Text(label) .font(.system(size: 10, weight: .medium)) .foregroundStyle(AppTheme.textTertiary) HStack(spacing: 0) { if let prefix { Text(prefix) .font(.system(size: 12, weight: .medium)) .foregroundStyle(AppTheme.textSecondary) .padding(.leading, 10) } TextField(placeholder, text: $text) .textFieldStyle(.plain) .font(.system(size: 12)) .foregroundStyle(AppTheme.textPrimary) .padding(.horizontal, prefix == nil ? 10 : 4) .padding(.vertical, 8) } .background(AppTheme.panelBackground) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(AppTheme.cardBorder, lineWidth: 1) ) } } } struct PostFormTextEditor: View { let label: String var placeholder: String = "" @Binding var text: String var minHeight: CGFloat = 120 var body: some View { VStack(alignment: .leading, spacing: 6) { Text(label) .font(.system(size: 10, weight: .medium)) .foregroundStyle(AppTheme.textTertiary) ZStack(alignment: .topLeading) { if text.isEmpty { Text(placeholder) .font(.system(size: 12)) .foregroundStyle(AppTheme.textTertiary.opacity(0.7)) .padding(.horizontal, 8) .padding(.vertical, 10) } TextEditor(text: $text) .font(.system(size: 12)) .foregroundStyle(AppTheme.textPrimary) .scrollContentBackground(.hidden) .padding(.horizontal, 4) .padding(.vertical, 4) } .frame(minHeight: minHeight) .background(AppTheme.panelBackground) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(AppTheme.cardBorder, lineWidth: 1) ) } } } struct PostTypePicker: View { @Binding var selectedType: RedditPostType var body: some View { LazyVGrid( columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8 ) { ForEach(RedditPostType.allCases) { type in PostTypeButton(type: type, isSelected: selectedType == type) { selectedType = type } } } } } private struct PostTypeButton: View { let type: RedditPostType let isSelected: Bool let action: () -> Void var body: some View { Button(action: action) { HStack(spacing: 8) { Image(systemName: type.systemImage) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(isSelected ? AppTheme.accentPurpleLight : AppTheme.textSecondary) .frame(width: 16) VStack(alignment: .leading, spacing: 1) { Text(type.title) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(AppTheme.textPrimary) Text(type.subtitle) .font(.system(size: 9)) .foregroundStyle(AppTheme.textTertiary) .lineLimit(1) } Spacer(minLength: 0) } .padding(.horizontal, 10) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 8) .fill(isSelected ? AppTheme.accentPurple.opacity(0.15) : AppTheme.panelBackground) ) .overlay( RoundedRectangle(cornerRadius: 8) .stroke( isSelected ? AppTheme.accentPurple.opacity(0.5) : AppTheme.cardBorder, lineWidth: 1 ) ) } .buttonStyle(.plain) } } struct PostTagToggle: View { let title: String let systemImage: String let activeColor: Color @Binding var isOn: Bool var body: some View { Button { isOn.toggle() } label: { HStack(spacing: 5) { Image(systemName: systemImage) .font(.system(size: 10, weight: .semibold)) Text(title) .font(.system(size: 10, weight: .semibold)) } .foregroundStyle(isOn ? activeColor : AppTheme.textSecondary) .padding(.horizontal, 10) .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: 6) .fill(isOn ? activeColor.opacity(0.15) : AppTheme.panelBackground) ) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(isOn ? activeColor.opacity(0.4) : AppTheme.cardBorder, lineWidth: 1) ) } .buttonStyle(.plain) } } struct TonePicker: View { @Binding var selectedTone: PostTone var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(PostTone.allCases) { tone in Button { selectedTone = tone } label: { Text(tone.title) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(selectedTone == tone ? .white : AppTheme.textSecondary) .padding(.horizontal, 10) .padding(.vertical, 6) .background( Capsule() .fill(selectedTone == tone ? AppTheme.accentPurple : AppTheme.panelBackground) ) .overlay( Capsule() .stroke( selectedTone == tone ? AppTheme.accentPurple.opacity(0.5) : AppTheme.cardBorder, lineWidth: 1 ) ) } .buttonStyle(.plain) } } } } } struct PostGeneratorMessageBanner: View { let message: String let isError: Bool var body: some View { HStack(spacing: 8) { Image(systemName: isError ? "exclamationmark.circle.fill" : "checkmark.circle.fill") .font(.system(size: 12)) Text(message) .font(.system(size: 11)) } .foregroundStyle(isError ? Color(hex: 0xF87171) : AppTheme.accentGreen) .padding(.horizontal, 12) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 8) .fill((isError ? Color(hex: 0xF87171) : AppTheme.accentGreen).opacity(0.1)) ) } } // MARK: - Markdown struct PostMarkdownText: View { let text: String var fontSize: CGFloat = 12 var color: Color = AppTheme.textSecondary var lineSpacing: CGFloat = 3 var body: some View { if let attributed = try? AttributedString( markdown: text, options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) ) { Text(attributed) .font(.system(size: fontSize)) .foregroundStyle(color) .lineSpacing(lineSpacing) .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) } else { Text(text) .font(.system(size: fontSize)) .foregroundStyle(color) .lineSpacing(lineSpacing) .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) } } } // MARK: - Reddit Preview Card struct RedditPostPreviewCard: View { let draft: PostDraft let formattedSubreddit: String var body: some View { VStack(alignment: .leading, spacing: 0) { previewHeader previewContent } .background(AppTheme.panelBackground) .clipShape(RoundedRectangle(cornerRadius: AppTheme.cornerRadius)) .overlay( RoundedRectangle(cornerRadius: AppTheme.cornerRadius) .stroke(AppTheme.cardBorder, lineWidth: 1) ) } private var previewHeader: some View { HStack(spacing: 8) { Circle() .fill( LinearGradient( colors: [AppTheme.accentOrange, AppTheme.accentOrange.opacity(0.7)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: 28, height: 28) .overlay { Image(systemName: "person.fill") .font(.system(size: 12)) .foregroundStyle(.white) } VStack(alignment: .leading, spacing: 1) { HStack(spacing: 4) { Text(formattedSubreddit) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(AppTheme.textPrimary) if !draft.flair.isEmpty { Text(draft.flair) .font(.system(size: 9, weight: .semibold)) .foregroundStyle(AppTheme.accentBlue) .padding(.horizontal, 6) .padding(.vertical, 2) .background(AppTheme.accentBlue.opacity(0.15)) .clipShape(Capsule()) } } Text("u/ReddoraUser ยท just now") .font(.system(size: 9)) .foregroundStyle(AppTheme.textTertiary) } Spacer() previewTags } .padding(.horizontal, 14) .padding(.top, 14) .padding(.bottom, 10) } @ViewBuilder private var previewTags: some View { HStack(spacing: 4) { if draft.isNSFW { previewTag("NSFW", color: Color(hex: 0xEF4444)) } if draft.isSpoiler { previewTag("Spoiler", color: AppTheme.textSecondary) } if draft.isOC { previewTag("OC", color: AppTheme.accentGreen) } } } private func previewTag(_ text: String, color: Color) -> some View { Text(text) .font(.system(size: 8, weight: .bold)) .foregroundStyle(color) .padding(.horizontal, 5) .padding(.vertical, 2) .background(color.opacity(0.15)) .clipShape(RoundedRectangle(cornerRadius: 3)) } @ViewBuilder private var previewContent: some View { VStack(alignment: .leading, spacing: 10) { Text(draft.title.isEmpty ? "Your post title will appear here" : draft.title) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(draft.title.isEmpty ? AppTheme.textTertiary : AppTheme.textPrimary) .fixedSize(horizontal: false, vertical: true) switch draft.postType { case .text: textPreview case .image: imagePreview case .link: linkPreview case .video: videoPreview case .poll: pollPreview } } .padding(.horizontal, 14) .padding(.bottom, 14) } @ViewBuilder private var textPreview: some View { if !draft.body.isEmpty { PostMarkdownText(text: draft.body) } } @ViewBuilder private var imagePreview: some View { if let url = draft.imageFileURL, let nsImage = NSImage(contentsOf: url) { Image(nsImage: nsImage) .resizable() .aspectRatio(contentMode: .fit) .frame(maxHeight: 200) .clipShape(RoundedRectangle(cornerRadius: 8)) } else { imagePlaceholder(icon: "photo", label: "Image preview") } if !draft.body.isEmpty { PostMarkdownText(text: draft.body, fontSize: 11) } } @ViewBuilder private var linkPreview: some View { linkCard( domain: linkDomain, title: draft.linkURL.isEmpty ? "Link preview" : draft.title, url: draft.linkURL ) if !draft.body.isEmpty { PostMarkdownText(text: draft.body, fontSize: 11) } } @ViewBuilder private var videoPreview: some View { if draft.videoURL.isEmpty { imagePlaceholder(icon: "play.rectangle", label: "Video preview") } else { linkCard( domain: videoDomain, title: draft.title.isEmpty ? "Video link" : draft.title, url: draft.videoURL ) } if !draft.body.isEmpty { PostMarkdownText(text: draft.body, fontSize: 11) } } @ViewBuilder private var pollPreview: some View { if !draft.body.isEmpty { PostMarkdownText(text: draft.body, fontSize: 11) } VStack(spacing: 6) { ForEach(draft.pollOptions) { option in HStack { Text(option.text.isEmpty ? "Poll option" : option.text) .font(.system(size: 11)) .foregroundStyle(option.text.isEmpty ? AppTheme.textTertiary : AppTheme.textPrimary) Spacer() Circle() .stroke(AppTheme.cardBorder, lineWidth: 1.5) .frame(width: 14, height: 14) } .padding(.horizontal, 10) .padding(.vertical, 8) .background(AppTheme.cardBackground) .clipShape(RoundedRectangle(cornerRadius: 6)) } } Text("Poll ends in \(draft.pollDuration.label)") .font(.system(size: 9)) .foregroundStyle(AppTheme.textTertiary) } private func imagePlaceholder(icon: String, label: String) -> some View { RoundedRectangle(cornerRadius: 8) .fill(AppTheme.cardBackground) .frame(height: 140) .overlay { VStack(spacing: 6) { Image(systemName: icon) .font(.system(size: 24)) .foregroundStyle(AppTheme.textTertiary) Text(label) .font(.system(size: 10)) .foregroundStyle(AppTheme.textTertiary) } } } private func linkCard(domain: String, title: String, url: String) -> some View { HStack(spacing: 10) { RoundedRectangle(cornerRadius: 6) .fill(AppTheme.cardBackground) .frame(width: 60, height: 60) .overlay { Image(systemName: "link") .font(.system(size: 18)) .foregroundStyle(AppTheme.textTertiary) } VStack(alignment: .leading, spacing: 3) { Text(domain.isEmpty ? "example.com" : domain) .font(.system(size: 9)) .foregroundStyle(AppTheme.textTertiary) Text(title) .font(.system(size: 11, weight: .medium)) .foregroundStyle(AppTheme.textPrimary) .lineLimit(2) } Spacer(minLength: 0) } .padding(10) .background(AppTheme.cardBackground) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(AppTheme.cardBorder, lineWidth: 1) ) } private var linkDomain: String { guard let url = URL(string: draft.linkURL), let host = url.host else { return "" } return host } private var videoDomain: String { guard let url = URL(string: draft.videoURL), let host = url.host else { return "" } return host } }