|
|
@@ -4,10 +4,12 @@ import UniformTypeIdentifiers
|
|
|
|
|
|
struct PostGeneratorView: View {
|
|
|
@Bindable var viewModel: PostGeneratorViewModel
|
|
|
+ let onOpenInReddit: (URL) -> Void
|
|
|
|
|
|
var body: some View {
|
|
|
VStack(spacing: 0) {
|
|
|
header
|
|
|
+ messageBanners
|
|
|
tabBar
|
|
|
|
|
|
ScrollView {
|
|
|
@@ -30,10 +32,32 @@ struct PostGeneratorView: View {
|
|
|
allowedContentTypes: [.image],
|
|
|
allowsMultipleSelection: false
|
|
|
) { result in
|
|
|
- if case .success(let urls) = result, let url = urls.first {
|
|
|
- viewModel.setImage(from: url)
|
|
|
+ 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) {
|
|
|
+ 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
|
|
|
@@ -43,10 +67,20 @@ struct PostGeneratorView: View {
|
|
|
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")
|
|
|
+ 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 OpenAI" : "Create Reddit-ready posts with AI templates")
|
|
|
.font(.system(size: 11))
|
|
|
.foregroundStyle(AppTheme.textSecondary)
|
|
|
}
|
|
|
@@ -64,6 +98,19 @@ struct PostGeneratorView: View {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ @ViewBuilder
|
|
|
+ private var messageBanners: some View {
|
|
|
+ if let error = viewModel.errorMessage {
|
|
|
+ PostGeneratorMessageBanner(message: error, isError: true)
|
|
|
+ .padding(.horizontal, 24)
|
|
|
+ .padding(.top, 8)
|
|
|
+ } else if let success = viewModel.successMessage {
|
|
|
+ PostGeneratorMessageBanner(message: success, isError: false)
|
|
|
+ .padding(.horizontal, 24)
|
|
|
+ .padding(.top, 8)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private var headerActions: some View {
|
|
|
HStack(spacing: 8) {
|
|
|
Button {
|
|
|
@@ -74,6 +121,15 @@ struct PostGeneratorView: View {
|
|
|
}
|
|
|
.buttonStyle(PostSecondaryButtonStyle())
|
|
|
|
|
|
+ Button {
|
|
|
+ viewModel.openInReddit(openInApp: onOpenInReddit)
|
|
|
+ } label: {
|
|
|
+ Label("Open in Reddit", systemImage: "arrow.up.right.square")
|
|
|
+ .font(.system(size: 11, weight: .medium))
|
|
|
+ }
|
|
|
+ .buttonStyle(PostSecondaryButtonStyle())
|
|
|
+ .disabled(!viewModel.canExport)
|
|
|
+
|
|
|
Button {
|
|
|
viewModel.copyToClipboard()
|
|
|
} label: {
|
|
|
@@ -81,7 +137,7 @@ struct PostGeneratorView: View {
|
|
|
.font(.system(size: 11, weight: .medium))
|
|
|
}
|
|
|
.buttonStyle(PostSecondaryButtonStyle())
|
|
|
- .disabled(viewModel.draft.title.isEmpty)
|
|
|
+ .disabled(!viewModel.canExport)
|
|
|
|
|
|
Button {
|
|
|
Task { await viewModel.generatePost() }
|
|
|
@@ -142,9 +198,16 @@ struct PostGeneratorView: View {
|
|
|
// MARK: - Compose
|
|
|
|
|
|
private var composeContent: some View {
|
|
|
- HStack(alignment: .top, spacing: 16) {
|
|
|
- configurationPanel
|
|
|
- editorPanel
|
|
|
+ ViewThatFits(in: .horizontal) {
|
|
|
+ HStack(alignment: .top, spacing: 16) {
|
|
|
+ configurationPanel
|
|
|
+ editorPanel
|
|
|
+ }
|
|
|
+
|
|
|
+ VStack(alignment: .leading, spacing: 16) {
|
|
|
+ configurationPanel
|
|
|
+ editorPanel
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -158,12 +221,21 @@ struct PostGeneratorView: View {
|
|
|
}
|
|
|
|
|
|
PostFormCard(title: "Target Subreddit", subtitle: "Where will this be posted?") {
|
|
|
- PostFormField(
|
|
|
- label: "Subreddit",
|
|
|
- placeholder: "technology",
|
|
|
- text: $viewModel.draft.subreddit,
|
|
|
- prefix: "r/"
|
|
|
- )
|
|
|
+ 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) {
|
|
|
+ Text("Use 3–21 characters: letters, numbers, underscores only.")
|
|
|
+ .font(.system(size: 9))
|
|
|
+ .foregroundStyle(Color(hex: 0xF87171))
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
PostFormCard(title: "AI Settings", subtitle: "Guide the tone and topic") {
|
|
|
@@ -213,14 +285,8 @@ struct PostGeneratorView: View {
|
|
|
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)
|
|
|
+ .frame(minWidth: 280, idealWidth: 300, maxWidth: 320)
|
|
|
}
|
|
|
|
|
|
private var editorPanel: some View {
|
|
|
@@ -291,6 +357,13 @@ struct PostGeneratorView: View {
|
|
|
text: $viewModel.draft.linkURL
|
|
|
)
|
|
|
|
|
|
+ if !viewModel.draft.linkURL.isEmpty,
|
|
|
+ !PostDraftValidator.isValidHTTPURL(viewModel.draft.linkURL) {
|
|
|
+ Text("Enter a valid http or https URL.")
|
|
|
+ .font(.system(size: 9))
|
|
|
+ .foregroundStyle(Color(hex: 0xF87171))
|
|
|
+ }
|
|
|
+
|
|
|
PostFormTextEditor(
|
|
|
label: "Description (optional)",
|
|
|
placeholder: "Why are you sharing this link?",
|
|
|
@@ -310,6 +383,13 @@ struct PostGeneratorView: View {
|
|
|
text: $viewModel.draft.videoURL
|
|
|
)
|
|
|
|
|
|
+ if !viewModel.draft.videoURL.isEmpty,
|
|
|
+ !PostDraftValidator.isValidHTTPURL(viewModel.draft.videoURL) {
|
|
|
+ Text("Enter a valid http or https URL.")
|
|
|
+ .font(.system(size: 9))
|
|
|
+ .foregroundStyle(Color(hex: 0xF87171))
|
|
|
+ }
|
|
|
+
|
|
|
PostFormTextEditor(
|
|
|
label: "Description (optional)",
|
|
|
placeholder: "Add context for your video…",
|
|
|
@@ -330,11 +410,7 @@ struct PostGeneratorView: View {
|
|
|
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
|
|
|
- }
|
|
|
- }
|
|
|
+ set: { viewModel.updatePollOption(id: option.id, text: $0) }
|
|
|
)
|
|
|
)
|
|
|
|
|
|
@@ -372,6 +448,7 @@ struct PostGeneratorView: View {
|
|
|
ForEach(PollDuration.allCases) { duration in
|
|
|
Button {
|
|
|
viewModel.draft.pollDuration = duration
|
|
|
+ viewModel.notifyDraftEdited()
|
|
|
} label: {
|
|
|
Text(duration.label)
|
|
|
.font(.system(size: 10, weight: .semibold))
|
|
|
@@ -466,39 +543,60 @@ struct PostGeneratorView: View {
|
|
|
Text(titleCharacterCount)
|
|
|
.font(.system(size: 10))
|
|
|
.foregroundStyle(
|
|
|
- viewModel.draft.title.count > 300 ? Color(hex: 0xF87171) : AppTheme.textTertiary
|
|
|
+ viewModel.draft.title.count > PostDraftValidator.maxTitleLength
|
|
|
+ ? Color(hex: 0xF87171)
|
|
|
+ : AppTheme.textTertiary
|
|
|
)
|
|
|
|
|
|
+ if !viewModel.draft.body.isEmpty {
|
|
|
+ Text(bodyCharacterCount)
|
|
|
+ .font(.system(size: 10))
|
|
|
+ .foregroundStyle(
|
|
|
+ viewModel.draft.body.count > PostDraftValidator.maxBodyLength
|
|
|
+ ? Color(hex: 0xF87171)
|
|
|
+ : AppTheme.textTertiary
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
Spacer()
|
|
|
|
|
|
- Text("Reddit limit: 300 characters for title")
|
|
|
+ Text("Reddit limits: 300 title · 40,000 body")
|
|
|
.font(.system(size: 10))
|
|
|
.foregroundStyle(AppTheme.textTertiary)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private var titleCharacterCount: String {
|
|
|
- "\(viewModel.draft.title.count) / 300"
|
|
|
+ "\(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(spacing: 16) {
|
|
|
- Text("Live Preview")
|
|
|
- .font(.system(size: 12, weight: .semibold))
|
|
|
- .foregroundStyle(AppTheme.textSecondary)
|
|
|
- .frame(maxWidth: .infinity, alignment: .leading)
|
|
|
+ HStack {
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
|
- RedditPostPreviewCard(
|
|
|
- draft: viewModel.draft,
|
|
|
- formattedSubreddit: viewModel.formattedSubreddit
|
|
|
- )
|
|
|
+ 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
|
|
|
+ previewMetadata
|
|
|
+ }
|
|
|
+ .frame(maxWidth: 560)
|
|
|
+
|
|
|
+ Spacer(minLength: 0)
|
|
|
}
|
|
|
- .frame(maxWidth: 560)
|
|
|
- .frame(maxWidth: .infinity)
|
|
|
}
|
|
|
|
|
|
private var previewMetadata: some View {
|
|
|
@@ -506,6 +604,7 @@ struct PostGeneratorView: View {
|
|
|
metadataRow(label: "Type", value: viewModel.draft.postType.title)
|
|
|
metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
|
|
|
metadataRow(label: "Tone", value: viewModel.draft.tone.title)
|
|
|
+ metadataRow(label: "Engine", value: viewModel.usesLiveAI ? "OpenAI" : "Local templates")
|
|
|
|
|
|
if viewModel.draft.isNSFW || viewModel.draft.isSpoiler || viewModel.draft.isOC {
|
|
|
metadataRow(
|
|
|
@@ -561,6 +660,6 @@ private struct PostSecondaryButtonStyle: ButtonStyle {
|
|
|
}
|
|
|
|
|
|
#Preview {
|
|
|
- PostGeneratorView(viewModel: PostGeneratorViewModel())
|
|
|
+ PostGeneratorView(viewModel: PostGeneratorViewModel()) { _ in }
|
|
|
.frame(width: 880, height: 720)
|
|
|
}
|