|
|
@@ -0,0 +1,503 @@
|
|
|
+import SwiftUI
|
|
|
+
|
|
|
+struct TitleOptimizerView: View {
|
|
|
+ @Bindable var viewModel: TitleOptimizerViewModel
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ VStack(spacing: 0) {
|
|
|
+ header
|
|
|
+ tabBar
|
|
|
+
|
|
|
+ ScrollView {
|
|
|
+ PostPageBoundary {
|
|
|
+ switch viewModel.selectedTab {
|
|
|
+ case .optimize:
|
|
|
+ optimizeContent
|
|
|
+ case .compare:
|
|
|
+ compareContent
|
|
|
+ case .preview:
|
|
|
+ previewContent
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .padding(.horizontal, 24)
|
|
|
+ .padding(.top, 8)
|
|
|
+ .padding(.bottom, 24)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .background(AppTheme.background)
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Header
|
|
|
+
|
|
|
+ private var header: some View {
|
|
|
+ HStack(spacing: 14) {
|
|
|
+ ModernSidebarIconView(kind: .titleOptimizer, size: 40)
|
|
|
+
|
|
|
+ VStack(alignment: .leading, spacing: 2) {
|
|
|
+ Text("Title Optimizer")
|
|
|
+ .font(.system(size: 18, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+ Text("Craft viral Reddit titles 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(TitleSecondaryButtonStyle())
|
|
|
+
|
|
|
+ Button {
|
|
|
+ viewModel.copyToClipboard()
|
|
|
+ } label: {
|
|
|
+ Label("Copy", systemImage: "doc.on.doc")
|
|
|
+ .font(.system(size: 11, weight: .medium))
|
|
|
+ }
|
|
|
+ .buttonStyle(TitleSecondaryButtonStyle())
|
|
|
+ .disabled(viewModel.activeTitle.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
|
+
|
|
|
+ Button {
|
|
|
+ Task { await viewModel.optimizeTitles() }
|
|
|
+ } label: {
|
|
|
+ HStack(spacing: 6) {
|
|
|
+ if viewModel.isOptimizing {
|
|
|
+ ProgressView()
|
|
|
+ .controlSize(.small)
|
|
|
+ .tint(.white)
|
|
|
+ } else {
|
|
|
+ Image(systemName: "sparkles")
|
|
|
+ .font(.system(size: 11, weight: .semibold))
|
|
|
+ }
|
|
|
+ Text(viewModel.isOptimizing ? "Optimizing…" : "Optimize")
|
|
|
+ .font(.system(size: 11, weight: .semibold))
|
|
|
+ }
|
|
|
+ .foregroundStyle(.white)
|
|
|
+ .padding(.horizontal, 14)
|
|
|
+ .padding(.vertical, 8)
|
|
|
+ .background(viewModel.canOptimize ? AppTheme.accentBlue : AppTheme.accentBlue.opacity(0.4))
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 8))
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ .disabled(!viewModel.canOptimize)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Tab Bar
|
|
|
+
|
|
|
+ private var tabBar: some View {
|
|
|
+ HStack(spacing: 4) {
|
|
|
+ ForEach(TitleOptimizerTab.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: - Optimize
|
|
|
+
|
|
|
+ private var optimizeContent: some View {
|
|
|
+ HStack(alignment: .top, spacing: 16) {
|
|
|
+ configurationPanel
|
|
|
+ editorPanel
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var configurationPanel: some View {
|
|
|
+ VStack(spacing: 12) {
|
|
|
+ PostFormCard(title: "Target Subreddit", subtitle: "Tailor titles for the community") {
|
|
|
+ PostFormField(
|
|
|
+ label: "Subreddit",
|
|
|
+ placeholder: "technology",
|
|
|
+ text: $viewModel.draft.subreddit,
|
|
|
+ prefix: "r/"
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ PostFormCard(title: "AI Settings", subtitle: "Guide topic, tone, and style") {
|
|
|
+ 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: "Title Goal", subtitle: "What kind of hook do you want?") {
|
|
|
+ TitleGoalPicker(selectedGoal: $viewModel.draft.titleGoal)
|
|
|
+ }
|
|
|
+
|
|
|
+ PostFormCard(title: "Post Context", subtitle: "Optional — affects preview style") {
|
|
|
+ VStack(alignment: .leading, spacing: 12) {
|
|
|
+ PostTypePicker(selectedType: Binding(
|
|
|
+ get: { viewModel.draft.postType },
|
|
|
+ set: { viewModel.selectPostType($0) }
|
|
|
+ ))
|
|
|
+
|
|
|
+ PostFormField(
|
|
|
+ label: "Flair",
|
|
|
+ placeholder: "Discussion, Question, Meme…",
|
|
|
+ text: $viewModel.draft.flair
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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: "Suggestions", subtitle: "How many variants to generate") {
|
|
|
+ TitleVariantCountPicker(selectedCount: $viewModel.draft.variantCount)
|
|
|
+ }
|
|
|
+
|
|
|
+ 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: "Original Title", subtitle: "The title you want to improve") {
|
|
|
+ PostFormField(
|
|
|
+ label: "Post Title",
|
|
|
+ placeholder: "Enter your draft title here",
|
|
|
+ text: $viewModel.draft.originalTitle
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ characterCountFooter
|
|
|
+
|
|
|
+ if let analysis = viewModel.analysis {
|
|
|
+ TitleAnalysisCard(analysis: analysis)
|
|
|
+ }
|
|
|
+
|
|
|
+ suggestionsSection
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ }
|
|
|
+
|
|
|
+ @ViewBuilder
|
|
|
+ private var suggestionsSection: some View {
|
|
|
+ PostFormCard(
|
|
|
+ title: "AI Suggestions",
|
|
|
+ subtitle: viewModel.variants.isEmpty
|
|
|
+ ? "Hit Optimize to generate title variants"
|
|
|
+ : "\(viewModel.variants.count) optimized titles ranked by score"
|
|
|
+ ) {
|
|
|
+ if viewModel.variants.isEmpty {
|
|
|
+ TitleOptimizerEmptyState(
|
|
|
+ icon: "textformat.size",
|
|
|
+ title: "No suggestions yet",
|
|
|
+ subtitle: "Fill in your subreddit, topic, and title,\nthen tap Optimize to get AI-powered variants."
|
|
|
+ )
|
|
|
+ } else {
|
|
|
+ VStack(spacing: 8) {
|
|
|
+ ForEach(viewModel.variants) { variant in
|
|
|
+ TitleVariantRow(
|
|
|
+ variant: variant,
|
|
|
+ isSelected: viewModel.selectedVariantID == variant.id,
|
|
|
+ onSelect: { viewModel.selectVariant(variant) },
|
|
|
+ onApply: { viewModel.applyVariant(variant) }
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var characterCountFooter: some View {
|
|
|
+ HStack {
|
|
|
+ Text("\(viewModel.draft.originalTitle.count) / 300")
|
|
|
+ .font(.system(size: 10))
|
|
|
+ .foregroundStyle(
|
|
|
+ viewModel.draft.originalTitle.count > 300 ? Color(hex: 0xF87171) : AppTheme.textTertiary
|
|
|
+ )
|
|
|
+
|
|
|
+ Spacer()
|
|
|
+
|
|
|
+ Text("Reddit limit: 300 characters for title")
|
|
|
+ .font(.system(size: 10))
|
|
|
+ .foregroundStyle(AppTheme.textTertiary)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Compare
|
|
|
+
|
|
|
+ private var compareContent: some View {
|
|
|
+ VStack(spacing: 16) {
|
|
|
+ if viewModel.variants.isEmpty {
|
|
|
+ TitleOptimizerEmptyState(
|
|
|
+ icon: "arrow.left.arrow.right",
|
|
|
+ title: "Nothing to compare yet",
|
|
|
+ subtitle: "Generate title suggestions on the Optimize tab first."
|
|
|
+ )
|
|
|
+ } else {
|
|
|
+ compareHeader
|
|
|
+
|
|
|
+ TitleCompareCard(
|
|
|
+ title: viewModel.draft.originalTitle,
|
|
|
+ score: viewModel.analysis?.overallScore,
|
|
|
+ label: "Original",
|
|
|
+ isOriginal: true,
|
|
|
+ isSelected: viewModel.selectedVariantID == nil
|
|
|
+ )
|
|
|
+
|
|
|
+ HStack {
|
|
|
+ Rectangle()
|
|
|
+ .fill(AppTheme.border)
|
|
|
+ .frame(height: 1)
|
|
|
+ Text("vs")
|
|
|
+ .font(.system(size: 10, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textTertiary)
|
|
|
+ .padding(.horizontal, 8)
|
|
|
+ Rectangle()
|
|
|
+ .fill(AppTheme.border)
|
|
|
+ .frame(height: 1)
|
|
|
+ }
|
|
|
+
|
|
|
+ VStack(spacing: 8) {
|
|
|
+ ForEach(viewModel.variants) { variant in
|
|
|
+ TitleCompareCard(
|
|
|
+ title: variant.title,
|
|
|
+ score: variant.score,
|
|
|
+ label: "Variant #\(variantRank(variant))",
|
|
|
+ isOriginal: false,
|
|
|
+ isSelected: viewModel.selectedVariantID == variant.id
|
|
|
+ )
|
|
|
+ .onTapGesture {
|
|
|
+ viewModel.selectVariant(variant)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ compareMetadata
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .frame(maxWidth: 640)
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ }
|
|
|
+
|
|
|
+ private var compareHeader: some View {
|
|
|
+ HStack {
|
|
|
+ VStack(alignment: .leading, spacing: 2) {
|
|
|
+ Text("Side-by-Side Comparison")
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+ Text("Tap a variant to select it as active")
|
|
|
+ .font(.system(size: 10))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ }
|
|
|
+
|
|
|
+ Spacer()
|
|
|
+
|
|
|
+ if let best = viewModel.bestVariant {
|
|
|
+ HStack(spacing: 6) {
|
|
|
+ Image(systemName: "trophy.fill")
|
|
|
+ .font(.system(size: 10))
|
|
|
+ .foregroundStyle(AppTheme.accentYellow)
|
|
|
+ Text("Best: \(best.score)/100")
|
|
|
+ .font(.system(size: 10, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var compareMetadata: some View {
|
|
|
+ VStack(alignment: .leading, spacing: 8) {
|
|
|
+ metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
|
|
|
+ metadataRow(label: "Goal", value: viewModel.draft.titleGoal.title)
|
|
|
+ metadataRow(label: "Tone", value: viewModel.draft.tone.title)
|
|
|
+ metadataRow(label: "Active", value: viewModel.activeTitle)
|
|
|
+ }
|
|
|
+ .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 variantRank(_ variant: TitleVariant) -> Int {
|
|
|
+ (viewModel.variants.firstIndex(where: { $0.id == variant.id }) ?? 0) + 1
|
|
|
+ }
|
|
|
+
|
|
|
+ // 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.previewDraft,
|
|
|
+ formattedSubreddit: viewModel.formattedSubreddit
|
|
|
+ )
|
|
|
+
|
|
|
+ PostFormCard(title: "Feed Preview", subtitle: "How your title looks in a crowded feed") {
|
|
|
+ VStack(spacing: 6) {
|
|
|
+ TitleFeedPreviewRow(
|
|
|
+ title: "Other trending post in this subreddit right now",
|
|
|
+ subreddit: viewModel.formattedSubreddit
|
|
|
+ )
|
|
|
+ TitleFeedPreviewRow(
|
|
|
+ title: viewModel.activeTitle,
|
|
|
+ subreddit: viewModel.formattedSubreddit,
|
|
|
+ isHighlighted: true
|
|
|
+ )
|
|
|
+ TitleFeedPreviewRow(
|
|
|
+ title: "Another popular discussion happening nearby",
|
|
|
+ subreddit: viewModel.formattedSubreddit
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ previewMetadata
|
|
|
+ }
|
|
|
+ .frame(maxWidth: 560)
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ }
|
|
|
+
|
|
|
+ private var previewMetadata: some View {
|
|
|
+ VStack(alignment: .leading, spacing: 8) {
|
|
|
+ metadataRow(label: "Active Title", value: viewModel.activeTitle)
|
|
|
+ metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
|
|
|
+ metadataRow(label: "Goal", value: viewModel.draft.titleGoal.title)
|
|
|
+ metadataRow(label: "Tone", value: viewModel.draft.tone.title)
|
|
|
+ metadataRow(label: "Type", value: viewModel.draft.postType.title)
|
|
|
+
|
|
|
+ if let score = viewModel.analysis?.overallScore {
|
|
|
+ metadataRow(label: "Score", value: "\(score)/100")
|
|
|
+ }
|
|
|
+
|
|
|
+ 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(alignment: .top) {
|
|
|
+ Text(label)
|
|
|
+ .font(.system(size: 10, weight: .medium))
|
|
|
+ .foregroundStyle(AppTheme.textTertiary)
|
|
|
+ .frame(width: 80, alignment: .leading)
|
|
|
+ Text(value)
|
|
|
+ .font(.system(size: 10))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ .fixedSize(horizontal: false, vertical: true)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct TitleSecondaryButtonStyle: 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 {
|
|
|
+ TitleOptimizerView(viewModel: TitleOptimizerViewModel())
|
|
|
+ .frame(width: 880, height: 720)
|
|
|
+}
|