|
|
@@ -6,23 +6,33 @@ struct PostGeneratorView: View {
|
|
|
@Environment(\.requirePremiumAccess) private var requirePremiumAccess
|
|
|
@Bindable var viewModel: PostGeneratorViewModel
|
|
|
|
|
|
+ private enum Layout {
|
|
|
+ static let horizontalPadding: CGFloat = 24
|
|
|
+ static let columnWidth: CGFloat = 300
|
|
|
+ static let columnSpacing: CGFloat = 20
|
|
|
+ static let sectionSpacing: CGFloat = 12
|
|
|
+ }
|
|
|
+
|
|
|
var body: some View {
|
|
|
VStack(spacing: 0) {
|
|
|
header
|
|
|
messageBanners
|
|
|
- tabBar
|
|
|
|
|
|
ScrollView {
|
|
|
- PostPageBoundary {
|
|
|
- switch viewModel.selectedTab {
|
|
|
- case .compose:
|
|
|
- composeContent
|
|
|
- case .preview:
|
|
|
- previewContent
|
|
|
+ VStack(alignment: .leading, spacing: 12) {
|
|
|
+ tabBar
|
|
|
+
|
|
|
+ PostPageBoundary {
|
|
|
+ switch viewModel.selectedTab {
|
|
|
+ case .compose:
|
|
|
+ composeContent
|
|
|
+ case .preview:
|
|
|
+ previewContent
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
- .padding(.horizontal, 24)
|
|
|
- .padding(.top, 8)
|
|
|
+ .padding(.horizontal, Layout.horizontalPadding)
|
|
|
+ .padding(.top, 12)
|
|
|
.padding(.bottom, 24)
|
|
|
}
|
|
|
}
|
|
|
@@ -90,7 +100,7 @@ struct PostGeneratorView: View {
|
|
|
|
|
|
headerActions
|
|
|
}
|
|
|
- .padding(.horizontal, 24)
|
|
|
+ .padding(.horizontal, Layout.horizontalPadding)
|
|
|
.padding(.vertical, 16)
|
|
|
.overlay(alignment: .bottom) {
|
|
|
Rectangle()
|
|
|
@@ -103,11 +113,11 @@ struct PostGeneratorView: View {
|
|
|
private var messageBanners: some View {
|
|
|
if let error = viewModel.errorMessage {
|
|
|
PostGeneratorMessageBanner(message: error, isError: true)
|
|
|
- .padding(.horizontal, 24)
|
|
|
+ .padding(.horizontal, Layout.horizontalPadding)
|
|
|
.padding(.top, 8)
|
|
|
} else if let success = viewModel.successMessage {
|
|
|
PostGeneratorMessageBanner(message: success, isError: false)
|
|
|
- .padding(.horizontal, 24)
|
|
|
+ .padding(.horizontal, Layout.horizontalPadding)
|
|
|
.padding(.top, 8)
|
|
|
}
|
|
|
}
|
|
|
@@ -161,51 +171,100 @@ struct PostGeneratorView: View {
|
|
|
// MARK: - Tab Bar
|
|
|
|
|
|
private var tabBar: some View {
|
|
|
- HStack(spacing: 4) {
|
|
|
+ HStack(spacing: 0) {
|
|
|
ForEach(PostGeneratorTab.allCases) { tab in
|
|
|
Button {
|
|
|
- viewModel.selectedTab = tab
|
|
|
+ withAnimation(.easeInOut(duration: 0.15)) {
|
|
|
+ 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)
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ .padding(.vertical, 7)
|
|
|
.background(
|
|
|
viewModel.selectedTab == tab
|
|
|
? AppTheme.cardBackground
|
|
|
: Color.clear
|
|
|
)
|
|
|
- .clipShape(RoundedRectangle(cornerRadius: 8))
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 7))
|
|
|
}
|
|
|
.buttonStyle(.plain)
|
|
|
}
|
|
|
-
|
|
|
- Spacer()
|
|
|
}
|
|
|
- .padding(.horizontal, 24)
|
|
|
- .padding(.top, 12)
|
|
|
- .padding(.bottom, 4)
|
|
|
+ .padding(3)
|
|
|
+ .frame(width: 220)
|
|
|
+ .background(AppTheme.panelBackground)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 9))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: 9)
|
|
|
+ .stroke(AppTheme.cardBorder, lineWidth: 1)
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
// MARK: - Compose
|
|
|
|
|
|
private var composeContent: some View {
|
|
|
ViewThatFits(in: .horizontal) {
|
|
|
- HStack(alignment: .top, spacing: 16) {
|
|
|
- configurationPanel
|
|
|
- editorPanel
|
|
|
+ composeColumns
|
|
|
+
|
|
|
+ VStack(alignment: .leading, spacing: Layout.columnSpacing) {
|
|
|
+ composeColumn(title: "Configuration", icon: "slider.horizontal.3") {
|
|
|
+ configurationPanel
|
|
|
+ }
|
|
|
+ composeColumn(title: "Post Content", icon: "doc.text") {
|
|
|
+ editorPanel
|
|
|
+ }
|
|
|
}
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- VStack(alignment: .leading, spacing: 16) {
|
|
|
+ private var composeColumns: some View {
|
|
|
+ HStack(alignment: .top, spacing: Layout.columnSpacing) {
|
|
|
+ composeColumn(title: "Configuration", icon: "slider.horizontal.3") {
|
|
|
configurationPanel
|
|
|
+ }
|
|
|
+ .frame(width: Layout.columnWidth)
|
|
|
+
|
|
|
+ Rectangle()
|
|
|
+ .fill(AppTheme.border)
|
|
|
+ .frame(width: 1)
|
|
|
+ .padding(.vertical, 2)
|
|
|
+
|
|
|
+ composeColumn(title: "Post Content", icon: "doc.text") {
|
|
|
editorPanel
|
|
|
}
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func composeColumn<Content: View>(
|
|
|
+ title: String,
|
|
|
+ icon: String,
|
|
|
+ @ViewBuilder content: () -> Content
|
|
|
+ ) -> some View {
|
|
|
+ VStack(alignment: .leading, spacing: Layout.sectionSpacing) {
|
|
|
+ composeSectionHeader(title: title, icon: icon)
|
|
|
+ content()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func composeSectionHeader(title: String, icon: String) -> some View {
|
|
|
+ HStack(spacing: 6) {
|
|
|
+ Image(systemName: icon)
|
|
|
+ .font(.system(size: 10, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.accentPurpleLight)
|
|
|
+ Text(title)
|
|
|
+ .font(.system(size: 11, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
}
|
|
|
+ .textCase(.uppercase)
|
|
|
+ .tracking(0.4)
|
|
|
}
|
|
|
|
|
|
private var configurationPanel: some View {
|
|
|
- VStack(spacing: 12) {
|
|
|
+ VStack(spacing: Layout.sectionSpacing) {
|
|
|
PostFormCard(title: "Post Type", subtitle: "Choose how you want to post") {
|
|
|
PostTypePicker(selectedType: Binding(
|
|
|
get: { viewModel.draft.postType },
|
|
|
@@ -224,9 +283,7 @@ struct PostGeneratorView: View {
|
|
|
|
|
|
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))
|
|
|
+ validationHint("Use 3–21 characters: letters, numbers, underscores only.")
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -248,42 +305,46 @@ struct PostGeneratorView: View {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- 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: "Labels", subtitle: "Tags and optional flair") {
|
|
|
+ VStack(alignment: .leading, spacing: 12) {
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ PostTagToggle(
|
|
|
+ title: "NSFW",
|
|
|
+ systemImage: "exclamationmark.triangle.fill",
|
|
|
+ activeColor: Color(hex: 0xEF4444),
|
|
|
+ isOn: $viewModel.draft.isNSFW
|
|
|
+ )
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+
|
|
|
+ PostTagToggle(
|
|
|
+ title: "Spoiler",
|
|
|
+ systemImage: "eye.slash.fill",
|
|
|
+ activeColor: AppTheme.textSecondary,
|
|
|
+ isOn: $viewModel.draft.isSpoiler
|
|
|
+ )
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+
|
|
|
+ PostTagToggle(
|
|
|
+ title: "OC",
|
|
|
+ systemImage: "star.fill",
|
|
|
+ activeColor: AppTheme.accentGreen,
|
|
|
+ isOn: $viewModel.draft.isOC
|
|
|
+ )
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ }
|
|
|
+
|
|
|
+ PostFormField(
|
|
|
+ label: "Flair",
|
|
|
+ placeholder: "Discussion, Question, Meme…",
|
|
|
+ text: $viewModel.draft.flair
|
|
|
)
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- PostFormCard(title: "Flair", subtitle: "Optional subreddit flair") {
|
|
|
- PostFormField(
|
|
|
- label: "Flair",
|
|
|
- placeholder: "Discussion, Question, Meme…",
|
|
|
- text: $viewModel.draft.flair
|
|
|
- )
|
|
|
- }
|
|
|
}
|
|
|
- .frame(minWidth: 280, idealWidth: 300, maxWidth: 320)
|
|
|
}
|
|
|
|
|
|
private var editorPanel: some View {
|
|
|
- VStack(spacing: 12) {
|
|
|
+ VStack(spacing: Layout.sectionSpacing) {
|
|
|
PostFormCard(title: "Title", subtitle: "Required for all post types") {
|
|
|
PostFormField(
|
|
|
label: "Post Title",
|
|
|
@@ -296,7 +357,6 @@ struct PostGeneratorView: View {
|
|
|
|
|
|
characterCountFooter
|
|
|
}
|
|
|
- .frame(maxWidth: .infinity)
|
|
|
}
|
|
|
|
|
|
@ViewBuilder
|
|
|
@@ -352,9 +412,7 @@ struct PostGeneratorView: View {
|
|
|
|
|
|
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))
|
|
|
+ validationHint("Enter a valid http or https URL.")
|
|
|
}
|
|
|
|
|
|
PostFormTextEditor(
|
|
|
@@ -378,9 +436,7 @@ struct PostGeneratorView: View {
|
|
|
|
|
|
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))
|
|
|
+ validationHint("Enter a valid http or https URL.")
|
|
|
}
|
|
|
|
|
|
PostFormTextEditor(
|
|
|
@@ -437,7 +493,7 @@ struct PostGeneratorView: View {
|
|
|
.font(.system(size: 10, weight: .medium))
|
|
|
.foregroundStyle(AppTheme.textTertiary)
|
|
|
|
|
|
- HStack(spacing: 6) {
|
|
|
+ HStack(spacing: 8) {
|
|
|
ForEach(PollDuration.allCases) { duration in
|
|
|
Button {
|
|
|
viewModel.draft.pollDuration = duration
|
|
|
@@ -448,10 +504,10 @@ struct PostGeneratorView: View {
|
|
|
.foregroundStyle(
|
|
|
viewModel.draft.pollDuration == duration ? .white : AppTheme.textSecondary
|
|
|
)
|
|
|
- .padding(.horizontal, 12)
|
|
|
- .padding(.vertical, 6)
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ .padding(.vertical, 7)
|
|
|
.background(
|
|
|
- Capsule()
|
|
|
+ RoundedRectangle(cornerRadius: 7)
|
|
|
.fill(
|
|
|
viewModel.draft.pollDuration == duration
|
|
|
? AppTheme.accentPurple
|
|
|
@@ -459,7 +515,7 @@ struct PostGeneratorView: View {
|
|
|
)
|
|
|
)
|
|
|
.overlay(
|
|
|
- Capsule()
|
|
|
+ RoundedRectangle(cornerRadius: 7)
|
|
|
.stroke(AppTheme.cardBorder, lineWidth: 1)
|
|
|
)
|
|
|
}
|
|
|
@@ -532,9 +588,9 @@ struct PostGeneratorView: View {
|
|
|
}
|
|
|
|
|
|
private var characterCountFooter: some View {
|
|
|
- HStack {
|
|
|
+ HStack(spacing: 12) {
|
|
|
Text(titleCharacterCount)
|
|
|
- .font(.system(size: 10))
|
|
|
+ .font(.system(size: 10, weight: .medium, design: .monospaced))
|
|
|
.foregroundStyle(
|
|
|
viewModel.draft.title.count > PostDraftValidator.maxTitleLength
|
|
|
? Color(hex: 0xF87171)
|
|
|
@@ -542,8 +598,11 @@ struct PostGeneratorView: View {
|
|
|
)
|
|
|
|
|
|
if !viewModel.draft.body.isEmpty {
|
|
|
+ Text("·")
|
|
|
+ .foregroundStyle(AppTheme.textTertiary)
|
|
|
+
|
|
|
Text(bodyCharacterCount)
|
|
|
- .font(.system(size: 10))
|
|
|
+ .font(.system(size: 10, weight: .medium, design: .monospaced))
|
|
|
.foregroundStyle(
|
|
|
viewModel.draft.body.count > PostDraftValidator.maxBodyLength
|
|
|
? Color(hex: 0xF87171)
|
|
|
@@ -557,6 +616,16 @@ struct PostGeneratorView: View {
|
|
|
.font(.system(size: 10))
|
|
|
.foregroundStyle(AppTheme.textTertiary)
|
|
|
}
|
|
|
+ .padding(.horizontal, 12)
|
|
|
+ .padding(.vertical, 9)
|
|
|
+ .background(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
|
|
|
+ .fill(AppTheme.cardBackground)
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
|
|
|
+ .stroke(AppTheme.cardBorder, lineWidth: 1)
|
|
|
+ )
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
private var titleCharacterCount: String {
|
|
|
@@ -570,36 +639,39 @@ struct PostGeneratorView: View {
|
|
|
// MARK: - Preview
|
|
|
|
|
|
private var previewContent: some View {
|
|
|
- HStack {
|
|
|
- Spacer(minLength: 0)
|
|
|
+ VStack(alignment: .leading, spacing: Layout.columnSpacing) {
|
|
|
+ composeSectionHeader(title: "Live Preview", icon: "eye")
|
|
|
|
|
|
- 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: Layout.sectionSpacing) {
|
|
|
+ RedditPostPreviewCard(
|
|
|
+ draft: viewModel.draft,
|
|
|
+ formattedSubreddit: viewModel.formattedSubreddit
|
|
|
+ )
|
|
|
|
|
|
- previewMetadata
|
|
|
- }
|
|
|
- .frame(maxWidth: 560)
|
|
|
+ previewMetadata
|
|
|
+ }
|
|
|
+ .frame(maxWidth: 560)
|
|
|
|
|
|
- Spacer(minLength: 0)
|
|
|
+ Spacer(minLength: 0)
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private var previewMetadata: some View {
|
|
|
- VStack(alignment: .leading, spacing: 8) {
|
|
|
+ VStack(alignment: .leading, spacing: 0) {
|
|
|
metadataRow(label: "Type", value: viewModel.draft.postType.title)
|
|
|
+ metadataDivider
|
|
|
metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
|
|
|
+ metadataDivider
|
|
|
metadataRow(label: "Tone", value: viewModel.draft.tone.title)
|
|
|
+ metadataDivider
|
|
|
metadataRow(label: "Engine", value: viewModel.usesLiveAI ? "OpenAI" : "Local templates")
|
|
|
|
|
|
if viewModel.draft.isNSFW || viewModel.draft.isSpoiler || viewModel.draft.isOC {
|
|
|
+ metadataDivider
|
|
|
metadataRow(
|
|
|
label: "Tags",
|
|
|
value: [
|
|
|
@@ -624,16 +696,34 @@ struct PostGeneratorView: View {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
+ private var metadataDivider: some View {
|
|
|
+ Rectangle()
|
|
|
+ .fill(AppTheme.border)
|
|
|
+ .frame(height: 1)
|
|
|
+ .padding(.vertical, 6)
|
|
|
+ }
|
|
|
+
|
|
|
private func metadataRow(label: String, value: String) -> some View {
|
|
|
- HStack {
|
|
|
+ HStack(alignment: .top, spacing: 12) {
|
|
|
Text(label)
|
|
|
.font(.system(size: 10, weight: .medium))
|
|
|
.foregroundStyle(AppTheme.textTertiary)
|
|
|
- .frame(width: 70, alignment: .leading)
|
|
|
+ .frame(width: 72, alignment: .leading)
|
|
|
Text(value)
|
|
|
.font(.system(size: 10))
|
|
|
.foregroundStyle(AppTheme.textSecondary)
|
|
|
+ .fixedSize(horizontal: false, vertical: true)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func validationHint(_ message: String) -> some View {
|
|
|
+ HStack(spacing: 4) {
|
|
|
+ Image(systemName: "exclamationmark.circle.fill")
|
|
|
+ .font(.system(size: 9))
|
|
|
+ Text(message)
|
|
|
+ .font(.system(size: 9))
|
|
|
}
|
|
|
+ .foregroundStyle(Color(hex: 0xF87171))
|
|
|
}
|
|
|
}
|
|
|
|