|
@@ -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)
|
|
|
|
|
+}
|