Procházet zdrojové kódy

Add AI Post Generator tool with compose and preview workflow.

Introduces models, mock generation service, and MVVM UI wired into the sidebar so users can draft Reddit posts by type, tone, and subreddit.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 před 1 měsícem
rodič
revize
9dc1b4d18d

+ 130 - 0
Reddit App/Models/RedditPostModels.swift

@@ -0,0 +1,130 @@
+import Foundation
+
+enum RedditPostType: String, CaseIterable, Identifiable {
+    case text
+    case image
+    case link
+    case video
+    case poll
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .text: "Text"
+        case .image: "Image"
+        case .link: "Link"
+        case .video: "Video"
+        case .poll: "Poll"
+        }
+    }
+
+    var subtitle: String {
+        switch self {
+        case .text: "Share thoughts & stories"
+        case .image: "Upload photos & memes"
+        case .link: "Share articles & URLs"
+        case .video: "Embed or link videos"
+        case .poll: "Ask the community"
+        }
+    }
+
+    var systemImage: String {
+        switch self {
+        case .text: "text.alignleft"
+        case .image: "photo"
+        case .link: "link"
+        case .video: "play.rectangle"
+        case .poll: "chart.bar"
+        }
+    }
+}
+
+enum PostTone: String, CaseIterable, Identifiable {
+    case casual
+    case professional
+    case humorous
+    case informative
+    case controversial
+    case storytelling
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .casual: "Casual"
+        case .professional: "Professional"
+        case .humorous: "Humorous"
+        case .informative: "Informative"
+        case .controversial: "Bold"
+        case .storytelling: "Storytelling"
+        }
+    }
+}
+
+enum PollDuration: Int, CaseIterable, Identifiable {
+    case oneDay = 1
+    case threeDays = 3
+    case sevenDays = 7
+
+    var id: Int { rawValue }
+
+    var label: String {
+        switch self {
+        case .oneDay: "1 day"
+        case .threeDays: "3 days"
+        case .sevenDays: "7 days"
+        }
+    }
+}
+
+enum PostGeneratorTab: String, CaseIterable, Identifiable {
+    case compose
+    case preview
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .compose: "Compose"
+        case .preview: "Preview"
+        }
+    }
+}
+
+struct PollOption: Identifiable, Equatable {
+    let id: UUID
+    var text: String
+
+    init(id: UUID = UUID(), text: String = "") {
+        self.id = id
+        self.text = text
+    }
+}
+
+struct PostDraft: Equatable {
+    var postType: RedditPostType = .text
+    var subreddit: String = ""
+    var topic: String = ""
+    var tone: PostTone = .casual
+    var title: String = ""
+    var body: String = ""
+    var linkURL: String = ""
+    var videoURL: String = ""
+    var imageFileURL: URL?
+    var pollOptions: [PollOption] = [
+        PollOption(text: ""),
+        PollOption(text: ""),
+    ]
+    var pollDuration: PollDuration = .threeDays
+    var isNSFW: Bool = false
+    var isSpoiler: Bool = false
+    var isOC: Bool = false
+    var flair: String = ""
+}
+
+struct GeneratedPost: Equatable {
+    var title: String
+    var body: String
+    var suggestedFlair: String?
+}

+ 111 - 0
Reddit App/Services/PostGenerationService.swift

@@ -0,0 +1,111 @@
+import Foundation
+
+protocol PostGenerationServiceProtocol: Sendable {
+    func generatePost(from draft: PostDraft) async throws -> GeneratedPost
+}
+
+enum PostGenerationError: LocalizedError {
+    case emptyTopic
+    case emptySubreddit
+
+    var errorDescription: String? {
+        switch self {
+        case .emptyTopic: "Enter a topic or keywords for AI generation."
+        case .emptySubreddit: "Enter a subreddit to tailor the post."
+        }
+    }
+}
+
+struct MockPostGenerationService: PostGenerationServiceProtocol {
+    func generatePost(from draft: PostDraft) async throws -> GeneratedPost {
+        guard !draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty else {
+            throw PostGenerationError.emptySubreddit
+        }
+        guard !draft.topic.trimmingCharacters(in: .whitespaces).isEmpty else {
+            throw PostGenerationError.emptyTopic
+        }
+
+        try await Task.sleep(for: .milliseconds(900))
+
+        let subreddit = draft.subreddit.replacingOccurrences(of: "r/", with: "")
+        let topic = draft.topic.trimmingCharacters(in: .whitespaces)
+        let tone = draft.tone.title.lowercased()
+
+        let title = makeTitle(topic: topic, subreddit: subreddit, tone: draft.tone)
+        let body = makeBody(
+            topic: topic,
+            subreddit: subreddit,
+            tone: tone,
+            postType: draft.postType
+        )
+        let flair = suggestFlair(for: draft.postType, subreddit: subreddit)
+
+        return GeneratedPost(title: title, body: body, suggestedFlair: flair)
+    }
+
+    private func makeTitle(topic: String, subreddit: String, tone: PostTone) -> String {
+        switch tone {
+        case .humorous:
+            "I tried \(topic) so you don't have to — r/\(subreddit) was not wrong"
+        case .professional:
+            "Analysis: \(topic) — findings from r/\(subreddit)"
+        case .informative:
+            "Everything you need to know about \(topic) [Guide]"
+        case .controversial:
+            "Unpopular opinion: \(topic) is overrated. Change my mind."
+        case .storytelling:
+            "How \(topic) completely changed my perspective"
+        case .casual:
+            "Anyone else obsessed with \(topic) lately?"
+        }
+    }
+
+    private func makeBody(
+        topic: String,
+        subreddit: String,
+        tone: String,
+        postType: RedditPostType
+    ) -> String {
+        switch postType {
+        case .text:
+            """
+            Hey r/\(subreddit),
+
+            I've been diving deep into **\(topic)** and wanted to share what I've learned.
+
+            **Key points:**
+            - Started exploring this after seeing it trending here
+            - The community's take has been incredibly helpful
+            - Still have a few open questions (see below)
+
+            **My experience:**
+            This started as a casual experiment but turned into something I genuinely care about. Would love to hear how others in this subreddit approach \(topic).
+
+            **Questions for the community:**
+            1. What's your go-to resource for \(topic)?
+            2. Any common mistakes beginners should avoid?
+            3. Who else here is working on something similar?
+
+            Written in a \(tone) tone — happy to refine based on feedback!
+            """
+        case .image:
+            "Caption: A visual breakdown of \(topic) — details in the comments. Let me know what you think!"
+        case .link:
+            "Found this great resource on \(topic). Thought r/\(subreddit) would find it useful. Summary in comments."
+        case .video:
+            "Just watched this breakdown of \(topic) and had to share. The section at 3:42 really nails it."
+        case .poll:
+            "Curious where r/\(subreddit) stands on \(topic). Vote below!"
+        }
+    }
+
+    private func suggestFlair(for postType: RedditPostType, subreddit: String) -> String {
+        switch postType {
+        case .text: "Discussion"
+        case .image: "Media"
+        case .link: "News"
+        case .video: "Video"
+        case .poll: "Poll"
+        }
+    }
+}

+ 149 - 0
Reddit App/ViewModels/PostGeneratorViewModel.swift

@@ -0,0 +1,149 @@
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+
+@MainActor
+@Observable
+final class PostGeneratorViewModel {
+    var draft = PostDraft()
+    var selectedTab: PostGeneratorTab = .compose
+    var isGenerating = false
+    var errorMessage: String?
+    var successMessage: String?
+    var showImageImporter = false
+
+    private let generationService: any PostGenerationServiceProtocol
+
+    init(generationService: any PostGenerationServiceProtocol = MockPostGenerationService()) {
+        self.generationService = generationService
+    }
+
+    var formattedSubreddit: String {
+        let trimmed = draft.subreddit.trimmingCharacters(in: .whitespaces)
+        guard !trimmed.isEmpty else { return "r/subreddit" }
+        return trimmed.hasPrefix("r/") ? trimmed : "r/\(trimmed)"
+    }
+
+    var canGenerate: Bool {
+        !draft.topic.trimmingCharacters(in: .whitespaces).isEmpty
+            && !draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty
+            && !isGenerating
+    }
+
+    var canAddPollOption: Bool {
+        draft.pollOptions.count < 6
+    }
+
+    var canRemovePollOption: Bool {
+        draft.pollOptions.count > 2
+    }
+
+    func selectPostType(_ type: RedditPostType) {
+        draft.postType = type
+        clearMessages()
+    }
+
+    func addPollOption() {
+        guard canAddPollOption else { return }
+        draft.pollOptions.append(PollOption())
+    }
+
+    func removePollOption(_ option: PollOption) {
+        guard canRemovePollOption else { return }
+        draft.pollOptions.removeAll { $0.id == option.id }
+    }
+
+    func setImage(from url: URL) {
+        draft.imageFileURL = url
+        clearMessages()
+    }
+
+    func removeImage() {
+        draft.imageFileURL = nil
+    }
+
+    func generatePost() async {
+        guard canGenerate else { return }
+
+        isGenerating = true
+        errorMessage = nil
+        successMessage = nil
+
+        do {
+            let result = try await generationService.generatePost(from: draft)
+            draft.title = result.title
+            if draft.postType == .text || draft.postType == .poll {
+                draft.body = result.body
+            }
+            if let flair = result.suggestedFlair, draft.flair.isEmpty {
+                draft.flair = flair
+            }
+            successMessage = "Post generated successfully."
+        } catch {
+            errorMessage = error.localizedDescription
+        }
+
+        isGenerating = false
+    }
+
+    func copyToClipboard() {
+        let content = exportText()
+        NSPasteboard.general.clearContents()
+        NSPasteboard.general.setString(content, forType: .string)
+        successMessage = "Copied to clipboard."
+        errorMessage = nil
+    }
+
+    func resetDraft() {
+        draft = PostDraft()
+        selectedTab = .compose
+        clearMessages()
+    }
+
+    func exportText() -> String {
+        var lines: [String] = []
+        lines.append("Subreddit: \(formattedSubreddit)")
+        lines.append("Type: \(draft.postType.title)")
+
+        if !draft.title.isEmpty {
+            lines.append("Title: \(draft.title)")
+        }
+
+        switch draft.postType {
+        case .text:
+            if !draft.body.isEmpty { lines.append("\n\(draft.body)") }
+        case .image:
+            if let url = draft.imageFileURL {
+                lines.append("Image: \(url.lastPathComponent)")
+            }
+            if !draft.body.isEmpty { lines.append("Caption: \(draft.body)") }
+        case .link:
+            if !draft.linkURL.isEmpty { lines.append("URL: \(draft.linkURL)") }
+            if !draft.body.isEmpty { lines.append("\n\(draft.body)") }
+        case .video:
+            if !draft.videoURL.isEmpty { lines.append("Video URL: \(draft.videoURL)") }
+            if !draft.body.isEmpty { lines.append("\n\(draft.body)") }
+        case .poll:
+            lines.append("Duration: \(draft.pollDuration.label)")
+            for (index, option) in draft.pollOptions.enumerated() where !option.text.isEmpty {
+                lines.append("Option \(index + 1): \(option.text)")
+            }
+            if !draft.body.isEmpty { lines.append("\n\(draft.body)") }
+        }
+
+        var tags: [String] = []
+        if draft.isNSFW { tags.append("NSFW") }
+        if draft.isSpoiler { tags.append("Spoiler") }
+        if draft.isOC { tags.append("OC") }
+        if !tags.isEmpty { lines.append("Tags: \(tags.joined(separator: ", "))") }
+
+        if !draft.flair.isEmpty { lines.append("Flair: \(draft.flair)") }
+
+        return lines.joined(separator: "\n")
+    }
+
+    private func clearMessages() {
+        errorMessage = nil
+        successMessage = nil
+    }
+}

+ 525 - 0
Reddit App/Views/Components/PostGeneratorComponents.swift

@@ -0,0 +1,525 @@
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+
+// MARK: - Shared Form Components
+
+struct PostFormCard<Content: View>: 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: - 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 {
+            Text(draft.body)
+                .font(.system(size: 12))
+                .foregroundStyle(AppTheme.textSecondary)
+                .lineSpacing(3)
+                .fixedSize(horizontal: false, vertical: true)
+        }
+    }
+
+    @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 {
+            Text(draft.body)
+                .font(.system(size: 11))
+                .foregroundStyle(AppTheme.textSecondary)
+        }
+    }
+
+    @ViewBuilder
+    private var linkPreview: some View {
+        linkCard(
+            domain: linkDomain,
+            title: draft.linkURL.isEmpty ? "Link preview" : draft.title,
+            url: draft.linkURL
+        )
+
+        if !draft.body.isEmpty {
+            Text(draft.body)
+                .font(.system(size: 11))
+                .foregroundStyle(AppTheme.textSecondary)
+        }
+    }
+
+    @ViewBuilder
+    private var videoPreview: some View {
+        if draft.videoURL.isEmpty {
+            imagePlaceholder(icon: "play.rectangle", label: "Video preview")
+        } else {
+            linkCard(
+                domain: videoDomain,
+                title: "Video link",
+                url: draft.videoURL
+            )
+        }
+
+        if !draft.body.isEmpty {
+            Text(draft.body)
+                .font(.system(size: 11))
+                .foregroundStyle(AppTheme.textSecondary)
+        }
+    }
+
+    @ViewBuilder
+    private var pollPreview: some View {
+        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
+    }
+}

+ 12 - 1
Reddit App/Views/FrontPageView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct FrontPageView: View {
     @State private var viewModel = FrontPageViewModel()
+    @State private var postGeneratorViewModel = PostGeneratorViewModel()
 
     var body: some View {
         HStack(spacing: 0) {
@@ -27,11 +28,21 @@ struct FrontPageView: View {
             .accessibilityHidden(!viewModel.isRedditActive)
 
             if !viewModel.isRedditActive, let item = viewModel.selectedNavItem {
-                ToolPlaceholderView(item: item)
+                toolContent(for: item)
             }
         }
         .background(AppTheme.background)
     }
+
+    @ViewBuilder
+    private func toolContent(for item: SidebarNavItem) -> some View {
+        switch item.iconKind {
+        case .postGenerator:
+            PostGeneratorView(viewModel: postGeneratorViewModel)
+        default:
+            ToolPlaceholderView(item: item)
+        }
+    }
 }
 
 private struct ToolPlaceholderView: View {

+ 565 - 0
Reddit App/Views/PostGeneratorView.swift

@@ -0,0 +1,565 @@
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct PostGeneratorView: View {
+    @Bindable var viewModel: PostGeneratorViewModel
+
+    var body: some View {
+        VStack(spacing: 0) {
+            header
+            tabBar
+
+            ScrollView {
+                switch viewModel.selectedTab {
+                case .compose:
+                    composeContent
+                case .preview:
+                    previewContent
+                }
+            }
+        }
+        .background(AppTheme.background)
+        .fileImporter(
+            isPresented: $viewModel.showImageImporter,
+            allowedContentTypes: [.image],
+            allowsMultipleSelection: false
+        ) { result in
+            if case .success(let urls) = result, let url = urls.first {
+                viewModel.setImage(from: url)
+            }
+        }
+    }
+
+    // MARK: - Header
+
+    private var header: some View {
+        HStack(spacing: 14) {
+            ModernSidebarIconView(kind: .postGenerator, size: 40)
+
+            VStack(alignment: .leading, spacing: 2) {
+                Text("Post Generator")
+                    .font(.system(size: 18, weight: .semibold))
+                    .foregroundStyle(AppTheme.textPrimary)
+                Text("Create Reddit-ready posts with AI")
+                    .font(.system(size: 11))
+                    .foregroundStyle(AppTheme.textSecondary)
+            }
+
+            Spacer()
+
+            headerActions
+        }
+        .padding(.horizontal, 24)
+        .padding(.vertical, 16)
+        .overlay(alignment: .bottom) {
+            Rectangle()
+                .fill(AppTheme.border)
+                .frame(height: 1)
+        }
+    }
+
+    private var headerActions: some View {
+        HStack(spacing: 8) {
+            Button {
+                viewModel.resetDraft()
+            } label: {
+                Label("Reset", systemImage: "arrow.counterclockwise")
+                    .font(.system(size: 11, weight: .medium))
+            }
+            .buttonStyle(PostSecondaryButtonStyle())
+
+            Button {
+                viewModel.copyToClipboard()
+            } label: {
+                Label("Copy", systemImage: "doc.on.doc")
+                    .font(.system(size: 11, weight: .medium))
+            }
+            .buttonStyle(PostSecondaryButtonStyle())
+            .disabled(viewModel.draft.title.isEmpty)
+
+            Button {
+                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(.plain)
+            .disabled(!viewModel.canGenerate)
+        }
+    }
+
+    // MARK: - Tab Bar
+
+    private var tabBar: some View {
+        HStack(spacing: 4) {
+            ForEach(PostGeneratorTab.allCases) { tab in
+                Button {
+                    viewModel.selectedTab = tab
+                } label: {
+                    Text(tab.title)
+                        .font(.system(size: 11, weight: .semibold))
+                        .foregroundStyle(viewModel.selectedTab == tab ? AppTheme.textPrimary : AppTheme.textSecondary)
+                        .padding(.horizontal, 14)
+                        .padding(.vertical, 8)
+                        .background(
+                            viewModel.selectedTab == tab
+                                ? AppTheme.cardBackground
+                                : Color.clear
+                        )
+                        .clipShape(RoundedRectangle(cornerRadius: 8))
+                }
+                .buttonStyle(.plain)
+            }
+
+            Spacer()
+        }
+        .padding(.horizontal, 24)
+        .padding(.top, 12)
+        .padding(.bottom, 4)
+    }
+
+    // MARK: - Compose
+
+    private var composeContent: some View {
+        HStack(alignment: .top, spacing: 16) {
+            configurationPanel
+            editorPanel
+        }
+        .padding(24)
+        .padding(.top, 8)
+    }
+
+    private var configurationPanel: some View {
+        VStack(spacing: 12) {
+            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?") {
+                PostFormField(
+                    label: "Subreddit",
+                    placeholder: "technology",
+                    text: $viewModel.draft.subreddit,
+                    prefix: "r/"
+                )
+            }
+
+            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: "Post Tags", subtitle: "Reddit content labels") {
+                HStack(spacing: 6) {
+                    PostTagToggle(
+                        title: "NSFW",
+                        systemImage: "exclamationmark.triangle.fill",
+                        activeColor: Color(hex: 0xEF4444),
+                        isOn: $viewModel.draft.isNSFW
+                    )
+                    PostTagToggle(
+                        title: "Spoiler",
+                        systemImage: "eye.slash.fill",
+                        activeColor: AppTheme.textSecondary,
+                        isOn: $viewModel.draft.isSpoiler
+                    )
+                    PostTagToggle(
+                        title: "OC",
+                        systemImage: "star.fill",
+                        activeColor: AppTheme.accentGreen,
+                        isOn: $viewModel.draft.isOC
+                    )
+                }
+            }
+
+            PostFormCard(title: "Flair", subtitle: "Optional subreddit flair") {
+                PostFormField(
+                    label: "Flair",
+                    placeholder: "Discussion, Question, Meme…",
+                    text: $viewModel.draft.flair
+                )
+            }
+
+            if let error = viewModel.errorMessage {
+                PostGeneratorMessageBanner(message: error, isError: true)
+            } else if let success = viewModel.successMessage {
+                PostGeneratorMessageBanner(message: success, isError: false)
+            }
+        }
+        .frame(width: 300)
+    }
+
+    private var editorPanel: some View {
+        VStack(spacing: 12) {
+            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
+
+            characterCountFooter
+        }
+        .frame(maxWidth: .infinity)
+    }
+
+    @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
+                )
+
+                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
+                )
+
+                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: { newValue in
+                                    if let idx = viewModel.draft.pollOptions.firstIndex(where: { $0.id == option.id }) {
+                                        viewModel.draft.pollOptions[idx].text = newValue
+                                    }
+                                }
+                            )
+                        )
+
+                        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(.plain)
+                            .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(.plain)
+                }
+
+                VStack(alignment: .leading, spacing: 6) {
+                    Text("Poll Duration")
+                        .font(.system(size: 10, weight: .medium))
+                        .foregroundStyle(AppTheme.textTertiary)
+
+                    HStack(spacing: 6) {
+                        ForEach(PollDuration.allCases) { duration in
+                            Button {
+                                viewModel.draft.pollDuration = duration
+                            } label: {
+                                Text(duration.label)
+                                    .font(.system(size: 10, weight: .semibold))
+                                    .foregroundStyle(
+                                        viewModel.draft.pollDuration == duration ? .white : AppTheme.textSecondary
+                                    )
+                                    .padding(.horizontal, 12)
+                                    .padding(.vertical, 6)
+                                    .background(
+                                        Capsule()
+                                            .fill(
+                                                viewModel.draft.pollDuration == duration
+                                                    ? AppTheme.accentPurple
+                                                    : AppTheme.panelBackground
+                                            )
+                                    )
+                                    .overlay(
+                                        Capsule()
+                                            .stroke(AppTheme.cardBorder, lineWidth: 1)
+                                    )
+                            }
+                            .buttonStyle(.plain)
+                        }
+                    }
+                }
+
+                PostFormTextEditor(
+                    label: "Context (optional)",
+                    placeholder: "Explain why you're running this poll…",
+                    text: $viewModel.draft.body,
+                    minHeight: 80
+                )
+            }
+        }
+    }
+
+    @ViewBuilder
+    private var imageUploadArea: some View {
+        if let url = viewModel.draft.imageFileURL, let nsImage = NSImage(contentsOf: url) {
+            ZStack(alignment: .topTrailing) {
+                Image(nsImage: nsImage)
+                    .resizable()
+                    .aspectRatio(contentMode: .fit)
+                    .frame(maxHeight: 180)
+                    .frame(maxWidth: .infinity)
+                    .clipShape(RoundedRectangle(cornerRadius: 8))
+
+                Button {
+                    viewModel.removeImage()
+                } label: {
+                    Image(systemName: "xmark.circle.fill")
+                        .font(.system(size: 18))
+                        .foregroundStyle(.white)
+                        .shadow(radius: 2)
+                }
+                .buttonStyle(.plain)
+                .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)
+                .frame(height: 140)
+                .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(.plain)
+        }
+    }
+
+    private var characterCountFooter: some View {
+        HStack {
+            Text(titleCharacterCount)
+                .font(.system(size: 10))
+                .foregroundStyle(
+                    viewModel.draft.title.count > 300 ? Color(hex: 0xF87171) : AppTheme.textTertiary
+                )
+
+            Spacer()
+
+            Text("Reddit limit: 300 characters for title")
+                .font(.system(size: 10))
+                .foregroundStyle(AppTheme.textTertiary)
+        }
+    }
+
+    private var titleCharacterCount: String {
+        "\(viewModel.draft.title.count) / 300"
+    }
+
+    // MARK: - Preview
+
+    private var previewContent: some View {
+        VStack(spacing: 16) {
+            Text("Live Preview")
+                .font(.system(size: 12, weight: .semibold))
+                .foregroundStyle(AppTheme.textSecondary)
+                .frame(maxWidth: .infinity, alignment: .leading)
+
+            RedditPostPreviewCard(
+                draft: viewModel.draft,
+                formattedSubreddit: viewModel.formattedSubreddit
+            )
+
+            previewMetadata
+        }
+        .padding(24)
+        .padding(.top, 8)
+        .frame(maxWidth: 560)
+        .frame(maxWidth: .infinity)
+    }
+
+    private var previewMetadata: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            metadataRow(label: "Type", value: viewModel.draft.postType.title)
+            metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
+            metadataRow(label: "Tone", value: viewModel.draft.tone.title)
+
+            if viewModel.draft.isNSFW || viewModel.draft.isSpoiler || viewModel.draft.isOC {
+                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 func metadataRow(label: String, value: String) -> some View {
+        HStack {
+            Text(label)
+                .font(.system(size: 10, weight: .medium))
+                .foregroundStyle(AppTheme.textTertiary)
+                .frame(width: 70, alignment: .leading)
+            Text(value)
+                .font(.system(size: 10))
+                .foregroundStyle(AppTheme.textSecondary)
+        }
+    }
+}
+
+private struct PostSecondaryButtonStyle: ButtonStyle {
+    func makeBody(configuration: Configuration) -> some View {
+        configuration.label
+            .foregroundStyle(AppTheme.textSecondary)
+            .padding(.horizontal, 12)
+            .padding(.vertical, 8)
+            .background(AppTheme.cardBackground.opacity(configuration.isPressed ? 0.6 : 1))
+            .clipShape(RoundedRectangle(cornerRadius: 8))
+            .overlay(
+                RoundedRectangle(cornerRadius: 8)
+                    .stroke(AppTheme.cardBorder, lineWidth: 1)
+            )
+    }
+}
+
+#Preview {
+    PostGeneratorView(viewModel: PostGeneratorViewModel())
+        .frame(width: 880, height: 720)
+}