TitleOptimizerView.swift 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  1. import SwiftUI
  2. struct TitleOptimizerView: View {
  3. @Environment(\.requirePremiumAccess) private var requirePremiumAccess
  4. @Bindable var viewModel: TitleOptimizerViewModel
  5. private enum Layout {
  6. static let horizontalPadding: CGFloat = 24
  7. static let columnWidth: CGFloat = 300
  8. static let columnSpacing: CGFloat = 20
  9. static let sectionSpacing: CGFloat = 12
  10. }
  11. var body: some View {
  12. VStack(spacing: 0) {
  13. header
  14. messageBanners
  15. ScrollView {
  16. VStack(alignment: .leading, spacing: 12) {
  17. tabBar
  18. PostPageBoundary {
  19. switch viewModel.selectedTab {
  20. case .optimize:
  21. optimizeContent
  22. case .compare:
  23. compareContent
  24. case .preview:
  25. previewContent
  26. }
  27. }
  28. }
  29. .padding(.horizontal, Layout.horizontalPadding)
  30. .padding(.top, 12)
  31. .padding(.bottom, 24)
  32. }
  33. }
  34. .background(AppTheme.background)
  35. }
  36. // MARK: - Header
  37. private var header: some View {
  38. HStack(spacing: 14) {
  39. ModernSidebarIconView(kind: .titleOptimizer, size: 40)
  40. VStack(alignment: .leading, spacing: 2) {
  41. Text("Title Optimizer")
  42. .font(.system(size: 18, weight: .semibold))
  43. .foregroundStyle(AppTheme.textPrimary)
  44. Text(viewModel.usesLiveAI ? "Craft viral Reddit titles with OpenAI" : "Craft viral Reddit titles with AI templates")
  45. .font(.system(size: 11))
  46. .foregroundStyle(AppTheme.textSecondary)
  47. }
  48. Spacer()
  49. headerActions
  50. }
  51. .padding(.horizontal, Layout.horizontalPadding)
  52. .padding(.vertical, 16)
  53. .overlay(alignment: .bottom) {
  54. Rectangle()
  55. .fill(AppTheme.border)
  56. .frame(height: 1)
  57. }
  58. }
  59. @ViewBuilder
  60. private var messageBanners: some View {
  61. if let error = viewModel.errorMessage {
  62. PostGeneratorMessageBanner(message: error, isError: true)
  63. .padding(.horizontal, Layout.horizontalPadding)
  64. .padding(.top, 8)
  65. } else if let success = viewModel.successMessage {
  66. PostGeneratorMessageBanner(message: success, isError: false)
  67. .padding(.horizontal, Layout.horizontalPadding)
  68. .padding(.top, 8)
  69. }
  70. }
  71. private var headerActions: some View {
  72. HStack(spacing: 8) {
  73. Button {
  74. viewModel.resetDraft()
  75. } label: {
  76. Label("Reset", systemImage: "arrow.counterclockwise")
  77. .font(.system(size: 11, weight: .medium))
  78. }
  79. .buttonStyle(AppSecondaryButtonStyle())
  80. Button {
  81. viewModel.copyToClipboard()
  82. } label: {
  83. Label("Copy", systemImage: "doc.on.doc")
  84. .font(.system(size: 11, weight: .medium))
  85. }
  86. .buttonStyle(AppSecondaryButtonStyle())
  87. .disabled(viewModel.activeTitle.trimmingCharacters(in: .whitespaces).isEmpty)
  88. Button {
  89. guard requirePremiumAccess() else { return }
  90. Task { await viewModel.optimizeTitles() }
  91. } label: {
  92. HStack(spacing: 6) {
  93. if viewModel.isOptimizing {
  94. ProgressView()
  95. .controlSize(.small)
  96. .tint(.white)
  97. } else {
  98. Image(systemName: "sparkles")
  99. .font(.system(size: 11, weight: .semibold))
  100. }
  101. Text(viewModel.isOptimizing ? "Optimizing…" : "Optimize")
  102. .font(.system(size: 11, weight: .semibold))
  103. }
  104. .foregroundStyle(.white)
  105. .padding(.horizontal, 14)
  106. .padding(.vertical, 8)
  107. .background(viewModel.canOptimize ? AppTheme.accentBlue : AppTheme.accentBlue.opacity(0.4))
  108. .clipShape(RoundedRectangle(cornerRadius: 8))
  109. }
  110. .buttonStyle(AppPrimaryButtonStyle())
  111. .disabled(!viewModel.canOptimize)
  112. }
  113. }
  114. // MARK: - Tab Bar
  115. private var tabBar: some View {
  116. HStack(spacing: 0) {
  117. ForEach(TitleOptimizerTab.allCases) { tab in
  118. Button {
  119. withAnimation(.easeInOut(duration: 0.15)) {
  120. viewModel.selectedTab = tab
  121. }
  122. } label: {
  123. Text(tab.title)
  124. .font(.system(size: 11, weight: .semibold))
  125. .foregroundStyle(viewModel.selectedTab == tab ? AppTheme.textPrimary : AppTheme.textSecondary)
  126. .frame(maxWidth: .infinity)
  127. .padding(.vertical, 7)
  128. .background(
  129. viewModel.selectedTab == tab
  130. ? AppTheme.cardBackground
  131. : Color.clear
  132. )
  133. .clipShape(RoundedRectangle(cornerRadius: 7))
  134. }
  135. .buttonStyle(AppPlainButtonStyle())
  136. .hoverOverlay(cornerRadius: 7, isEnabled: viewModel.selectedTab != tab)
  137. }
  138. }
  139. .padding(3)
  140. .frame(width: 300)
  141. .background(AppTheme.panelBackground)
  142. .clipShape(RoundedRectangle(cornerRadius: 9))
  143. .overlay(
  144. RoundedRectangle(cornerRadius: 9)
  145. .stroke(AppTheme.cardBorder, lineWidth: 1)
  146. )
  147. }
  148. // MARK: - Optimize
  149. private var optimizeContent: some View {
  150. ViewThatFits(in: .horizontal) {
  151. optimizeColumns
  152. VStack(alignment: .leading, spacing: Layout.columnSpacing) {
  153. optimizeColumn(title: "Configuration", icon: "slider.horizontal.3") {
  154. configurationPanel
  155. }
  156. optimizeColumn(title: "Title & Suggestions", icon: "textformat") {
  157. editorPanel
  158. }
  159. }
  160. }
  161. }
  162. private var optimizeColumns: some View {
  163. HStack(alignment: .top, spacing: Layout.columnSpacing) {
  164. optimizeColumn(title: "Configuration", icon: "slider.horizontal.3") {
  165. configurationPanel
  166. }
  167. .frame(width: Layout.columnWidth)
  168. Rectangle()
  169. .fill(AppTheme.border)
  170. .frame(width: 1)
  171. .padding(.vertical, 2)
  172. optimizeColumn(title: "Title & Suggestions", icon: "textformat") {
  173. editorPanel
  174. }
  175. .frame(maxWidth: .infinity)
  176. }
  177. }
  178. private func optimizeColumn<Content: View>(
  179. title: String,
  180. icon: String,
  181. @ViewBuilder content: () -> Content
  182. ) -> some View {
  183. VStack(alignment: .leading, spacing: Layout.sectionSpacing) {
  184. sectionHeader(title: title, icon: icon)
  185. content()
  186. }
  187. }
  188. private func sectionHeader(title: String, icon: String) -> some View {
  189. HStack(spacing: 6) {
  190. Image(systemName: icon)
  191. .font(.system(size: 10, weight: .semibold))
  192. .foregroundStyle(AppTheme.accentBlue)
  193. Text(title)
  194. .font(.system(size: 11, weight: .semibold))
  195. .foregroundStyle(AppTheme.textSecondary)
  196. }
  197. .textCase(.uppercase)
  198. .tracking(0.4)
  199. }
  200. private var configurationPanel: some View {
  201. VStack(spacing: Layout.sectionSpacing) {
  202. PostFormCard(title: "Target Subreddit", subtitle: "Tailor titles for the community") {
  203. PostFormField(
  204. label: "Subreddit",
  205. placeholder: "technology",
  206. text: $viewModel.draft.subreddit,
  207. prefix: "r/"
  208. )
  209. }
  210. PostFormCard(title: "AI Settings", subtitle: "Guide topic, tone, and style") {
  211. VStack(alignment: .leading, spacing: 12) {
  212. PostFormField(
  213. label: "Topic / Keywords",
  214. placeholder: "e.g. macOS productivity tips",
  215. text: $viewModel.draft.topic
  216. )
  217. VStack(alignment: .leading, spacing: 6) {
  218. Text("Tone")
  219. .font(.system(size: 10, weight: .medium))
  220. .foregroundStyle(AppTheme.textTertiary)
  221. TonePicker(selectedTone: $viewModel.draft.tone)
  222. }
  223. }
  224. }
  225. PostFormCard(title: "Title Goal", subtitle: "What kind of hook do you want?") {
  226. TitleGoalPicker(selectedGoal: $viewModel.draft.titleGoal)
  227. }
  228. PostFormCard(title: "Preview Context", subtitle: "Optional — affects preview style") {
  229. VStack(alignment: .leading, spacing: 12) {
  230. PostTypePicker(selectedType: Binding(
  231. get: { viewModel.draft.postType },
  232. set: { viewModel.selectPostType($0) }
  233. ))
  234. HStack(spacing: 8) {
  235. PostTagToggle(
  236. title: "NSFW",
  237. systemImage: "exclamationmark.triangle.fill",
  238. activeColor: Color(hex: 0xEF4444),
  239. isOn: $viewModel.draft.isNSFW
  240. )
  241. .frame(maxWidth: .infinity)
  242. PostTagToggle(
  243. title: "Spoiler",
  244. systemImage: "eye.slash.fill",
  245. activeColor: AppTheme.textSecondary,
  246. isOn: $viewModel.draft.isSpoiler
  247. )
  248. .frame(maxWidth: .infinity)
  249. PostTagToggle(
  250. title: "OC",
  251. systemImage: "star.fill",
  252. activeColor: AppTheme.accentGreen,
  253. isOn: $viewModel.draft.isOC
  254. )
  255. .frame(maxWidth: .infinity)
  256. }
  257. PostFormField(
  258. label: "Flair",
  259. placeholder: "Discussion, Question, Meme…",
  260. text: $viewModel.draft.flair
  261. )
  262. }
  263. }
  264. PostFormCard(title: "Suggestions", subtitle: "How many variants to generate") {
  265. TitleVariantCountPicker(selectedCount: $viewModel.draft.variantCount)
  266. }
  267. }
  268. }
  269. private var editorPanel: some View {
  270. VStack(spacing: Layout.sectionSpacing) {
  271. PostFormCard(title: "Original Title", subtitle: "The title you want to improve") {
  272. PostFormField(
  273. label: "Post Title",
  274. placeholder: "Enter your draft title here",
  275. text: $viewModel.draft.originalTitle
  276. )
  277. }
  278. characterCountFooter
  279. if let analysis = viewModel.analysis {
  280. TitleAnalysisCard(analysis: analysis)
  281. }
  282. suggestionsSection
  283. }
  284. }
  285. @ViewBuilder
  286. private var suggestionsSection: some View {
  287. PostFormCard(
  288. title: "AI Suggestions",
  289. subtitle: viewModel.variants.isEmpty
  290. ? "Hit Optimize to generate title variants"
  291. : "\(viewModel.variants.count) optimized titles ranked by score"
  292. ) {
  293. if viewModel.variants.isEmpty {
  294. TitleOptimizerEmptyState(
  295. icon: "textformat.size",
  296. title: "No suggestions yet",
  297. subtitle: "Fill in your subreddit, topic, and title,\nthen tap Optimize to get AI-powered variants."
  298. )
  299. } else {
  300. VStack(spacing: 8) {
  301. ForEach(viewModel.variants) { variant in
  302. TitleVariantRow(
  303. variant: variant,
  304. isSelected: viewModel.selectedVariantID == variant.id,
  305. onSelect: { viewModel.selectVariant(variant) },
  306. onApply: { viewModel.applyVariant(variant) }
  307. )
  308. }
  309. }
  310. }
  311. }
  312. }
  313. private var characterCountFooter: some View {
  314. HStack(spacing: 12) {
  315. Text("\(viewModel.draft.originalTitle.count) / 300")
  316. .font(.system(size: 10, weight: .medium, design: .monospaced))
  317. .foregroundStyle(
  318. viewModel.draft.originalTitle.count > 300 ? Color(hex: 0xF87171) : AppTheme.textTertiary
  319. )
  320. Spacer()
  321. Text("Reddit limit: 300 characters for title")
  322. .font(.system(size: 10))
  323. .foregroundStyle(AppTheme.textTertiary)
  324. }
  325. .padding(.horizontal, 12)
  326. .padding(.vertical, 9)
  327. .background(
  328. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  329. .fill(AppTheme.cardBackground)
  330. .overlay(
  331. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  332. .stroke(AppTheme.cardBorder, lineWidth: 1)
  333. )
  334. )
  335. }
  336. // MARK: - Compare
  337. private var compareContent: some View {
  338. VStack(alignment: .leading, spacing: Layout.columnSpacing) {
  339. compareHeader
  340. if viewModel.variants.isEmpty {
  341. TitleOptimizerEmptyState(
  342. icon: "arrow.left.arrow.right",
  343. title: "Nothing to compare yet",
  344. subtitle: "Generate title suggestions on the Optimize tab first."
  345. )
  346. .frame(maxWidth: .infinity)
  347. } else {
  348. compareBody
  349. }
  350. }
  351. }
  352. private var compareBody: some View {
  353. VStack(spacing: Layout.sectionSpacing) {
  354. TitleCompareCard(
  355. title: viewModel.draft.originalTitle,
  356. score: viewModel.analysis?.overallScore,
  357. label: "Original",
  358. isOriginal: true,
  359. isSelected: viewModel.selectedVariantID == nil
  360. )
  361. HStack {
  362. Rectangle()
  363. .fill(AppTheme.border)
  364. .frame(height: 1)
  365. Text("vs")
  366. .font(.system(size: 10, weight: .semibold))
  367. .foregroundStyle(AppTheme.textTertiary)
  368. .padding(.horizontal, 8)
  369. Rectangle()
  370. .fill(AppTheme.border)
  371. .frame(height: 1)
  372. }
  373. VStack(spacing: 8) {
  374. ForEach(viewModel.variants) { variant in
  375. TitleCompareCard(
  376. title: variant.title,
  377. score: variant.score,
  378. label: "Variant #\(variantRank(variant))",
  379. isOriginal: false,
  380. isSelected: viewModel.selectedVariantID == variant.id
  381. )
  382. .onTapGesture {
  383. viewModel.selectVariant(variant)
  384. }
  385. }
  386. }
  387. compareMetadata
  388. }
  389. .frame(maxWidth: 640)
  390. .frame(maxWidth: .infinity)
  391. }
  392. private var compareHeader: some View {
  393. HStack(alignment: .top) {
  394. sectionHeader(title: "Side-by-Side Comparison", icon: "arrow.left.arrow.right")
  395. Spacer()
  396. if let best = viewModel.bestVariant {
  397. HStack(spacing: 6) {
  398. Image(systemName: "trophy.fill")
  399. .font(.system(size: 10))
  400. .foregroundStyle(AppTheme.accentYellow)
  401. Text("Best: \(best.score)/100")
  402. .font(.system(size: 10, weight: .semibold))
  403. .foregroundStyle(AppTheme.textSecondary)
  404. }
  405. .padding(.top, 1)
  406. }
  407. }
  408. }
  409. private var compareMetadata: some View {
  410. VStack(alignment: .leading, spacing: 0) {
  411. metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
  412. metadataDivider
  413. metadataRow(label: "Goal", value: viewModel.draft.titleGoal.title)
  414. metadataDivider
  415. metadataRow(label: "Tone", value: viewModel.draft.tone.title)
  416. metadataDivider
  417. metadataRow(label: "Active", value: viewModel.activeTitle)
  418. }
  419. .padding(14)
  420. .frame(maxWidth: .infinity, alignment: .leading)
  421. .background(
  422. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  423. .fill(AppTheme.cardBackground)
  424. .overlay(
  425. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  426. .stroke(AppTheme.cardBorder, lineWidth: 1)
  427. )
  428. )
  429. }
  430. private func variantRank(_ variant: TitleVariant) -> Int {
  431. (viewModel.variants.firstIndex(where: { $0.id == variant.id }) ?? 0) + 1
  432. }
  433. // MARK: - Preview
  434. private var previewContent: some View {
  435. VStack(alignment: .leading, spacing: Layout.columnSpacing) {
  436. sectionHeader(title: "Live Preview", icon: "eye")
  437. HStack {
  438. Spacer(minLength: 0)
  439. VStack(spacing: Layout.sectionSpacing) {
  440. RedditPostPreviewCard(
  441. draft: viewModel.previewDraft,
  442. formattedSubreddit: viewModel.formattedSubreddit
  443. )
  444. PostFormCard(title: "Feed Preview", subtitle: "How your title looks in a crowded feed") {
  445. VStack(spacing: 6) {
  446. TitleFeedPreviewRow(
  447. title: "Other trending post in this subreddit right now",
  448. subreddit: viewModel.formattedSubreddit
  449. )
  450. TitleFeedPreviewRow(
  451. title: viewModel.activeTitle,
  452. subreddit: viewModel.formattedSubreddit,
  453. isHighlighted: true
  454. )
  455. TitleFeedPreviewRow(
  456. title: "Another popular discussion happening nearby",
  457. subreddit: viewModel.formattedSubreddit
  458. )
  459. }
  460. }
  461. previewMetadata
  462. }
  463. .frame(maxWidth: 560)
  464. Spacer(minLength: 0)
  465. }
  466. }
  467. }
  468. private var previewMetadata: some View {
  469. VStack(alignment: .leading, spacing: 0) {
  470. metadataRow(label: "Active Title", value: viewModel.activeTitle)
  471. metadataDivider
  472. metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
  473. metadataDivider
  474. metadataRow(label: "Goal", value: viewModel.draft.titleGoal.title)
  475. metadataDivider
  476. metadataRow(label: "Tone", value: viewModel.draft.tone.title)
  477. metadataDivider
  478. metadataRow(label: "Type", value: viewModel.draft.postType.title)
  479. if let score = viewModel.analysis?.overallScore {
  480. metadataDivider
  481. metadataRow(label: "Score", value: "\(score)/100")
  482. }
  483. if viewModel.draft.isNSFW || viewModel.draft.isSpoiler || viewModel.draft.isOC {
  484. metadataDivider
  485. metadataRow(
  486. label: "Tags",
  487. value: [
  488. viewModel.draft.isNSFW ? "NSFW" : nil,
  489. viewModel.draft.isSpoiler ? "Spoiler" : nil,
  490. viewModel.draft.isOC ? "OC" : nil,
  491. ]
  492. .compactMap { $0 }
  493. .joined(separator: ", ")
  494. )
  495. }
  496. }
  497. .padding(14)
  498. .frame(maxWidth: .infinity, alignment: .leading)
  499. .background(
  500. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  501. .fill(AppTheme.cardBackground)
  502. .overlay(
  503. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  504. .stroke(AppTheme.cardBorder, lineWidth: 1)
  505. )
  506. )
  507. }
  508. private var metadataDivider: some View {
  509. Rectangle()
  510. .fill(AppTheme.border)
  511. .frame(height: 1)
  512. .padding(.vertical, 6)
  513. }
  514. private func metadataRow(label: String, value: String) -> some View {
  515. HStack(alignment: .top, spacing: 12) {
  516. Text(label)
  517. .font(.system(size: 10, weight: .medium))
  518. .foregroundStyle(AppTheme.textTertiary)
  519. .frame(width: 72, alignment: .leading)
  520. Text(value)
  521. .font(.system(size: 10))
  522. .foregroundStyle(AppTheme.textSecondary)
  523. .fixedSize(horizontal: false, vertical: true)
  524. }
  525. }
  526. }
  527. private struct TitleSecondaryButtonStyle: ButtonStyle {
  528. func makeBody(configuration: Configuration) -> some View {
  529. AppSecondaryButtonStyle().makeBody(configuration: configuration)
  530. }
  531. }
  532. #Preview {
  533. TitleOptimizerView(viewModel: TitleOptimizerViewModel())
  534. .frame(width: 880, height: 720)
  535. }