CommentWriterView.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. import SwiftUI
  2. struct CommentWriterView: View {
  3. @Environment(\.requirePremiumAccess) private var requirePremiumAccess
  4. @Bindable var viewModel: CommentWriterViewModel
  5. let onOpenInReddit: (URL) -> Void
  6. var body: some View {
  7. VStack(spacing: 0) {
  8. header
  9. messageBanners
  10. tabBar
  11. ScrollView {
  12. PostPageBoundary {
  13. switch viewModel.selectedTab {
  14. case .compose:
  15. composeContent
  16. case .preview:
  17. previewContent
  18. case .variants:
  19. variantsContent
  20. }
  21. }
  22. .padding(.horizontal, 24)
  23. .padding(.top, 8)
  24. .padding(.bottom, 24)
  25. }
  26. }
  27. .background(AppTheme.background)
  28. .alert("Replace existing comment?", isPresented: $viewModel.showOverwriteConfirmation) {
  29. Button("Cancel", role: .cancel) {
  30. viewModel.cancelOverwriteConfirmation()
  31. }
  32. Button("Replace", role: .destructive) {
  33. guard requirePremiumAccess() else { return }
  34. Task { await viewModel.confirmOverwriteAndGenerate() }
  35. }
  36. } message: {
  37. Text("Generating will replace your current comment text.")
  38. }
  39. .onChange(of: viewModel.draft.subreddit) { _, _ in viewModel.notifyDraftEdited() }
  40. .onChange(of: viewModel.draft.postTitle) { _, _ in viewModel.notifyDraftEdited() }
  41. .onChange(of: viewModel.draft.postBody) { _, _ in viewModel.notifyDraftEdited() }
  42. .onChange(of: viewModel.draft.parentComment) { _, _ in viewModel.notifyDraftEdited() }
  43. .onChange(of: viewModel.draft.body) { _, _ in viewModel.notifyDraftEdited() }
  44. }
  45. // MARK: - Header
  46. private var header: some View {
  47. HStack(spacing: 14) {
  48. ModernSidebarIconView(kind: .commentWriter, size: 40)
  49. VStack(alignment: .leading, spacing: 2) {
  50. HStack(spacing: 6) {
  51. Text("Comment Writer")
  52. .font(.system(size: 18, weight: .semibold))
  53. .foregroundStyle(AppTheme.textPrimary)
  54. if viewModel.hasDraftChanges {
  55. Circle()
  56. .fill(AppTheme.accentGreen)
  57. .frame(width: 6, height: 6)
  58. .help("Draft has unsaved edits")
  59. }
  60. }
  61. Text(viewModel.usesLiveAI ? "Write Reddit-ready comments with OpenAI" : "Write Reddit-ready comments with AI templates")
  62. .font(.system(size: 11))
  63. .foregroundStyle(AppTheme.textSecondary)
  64. }
  65. Spacer()
  66. headerActions
  67. }
  68. .padding(.horizontal, 24)
  69. .padding(.vertical, 16)
  70. .overlay(alignment: .bottom) {
  71. Rectangle()
  72. .fill(AppTheme.border)
  73. .frame(height: 1)
  74. }
  75. }
  76. @ViewBuilder
  77. private var messageBanners: some View {
  78. if let error = viewModel.errorMessage {
  79. PostGeneratorMessageBanner(message: error, isError: true)
  80. .padding(.horizontal, 24)
  81. .padding(.top, 8)
  82. } else if let success = viewModel.successMessage {
  83. PostGeneratorMessageBanner(message: success, isError: false)
  84. .padding(.horizontal, 24)
  85. .padding(.top, 8)
  86. }
  87. }
  88. private var headerActions: some View {
  89. HStack(spacing: 8) {
  90. Button {
  91. viewModel.resetDraft()
  92. } label: {
  93. Label("Reset", systemImage: "arrow.counterclockwise")
  94. .font(.system(size: 11, weight: .medium))
  95. }
  96. .buttonStyle(CommentSecondaryButtonStyle())
  97. Button {
  98. viewModel.openInReddit(openInApp: onOpenInReddit)
  99. } label: {
  100. Label("Open Thread", systemImage: "arrow.up.right.square")
  101. .font(.system(size: 11, weight: .medium))
  102. }
  103. .buttonStyle(CommentSecondaryButtonStyle())
  104. .disabled(viewModel.draft.postURL.trimmingCharacters(in: .whitespaces).isEmpty)
  105. Button {
  106. viewModel.copyToClipboard()
  107. } label: {
  108. Label("Copy", systemImage: "doc.on.doc")
  109. .font(.system(size: 11, weight: .medium))
  110. }
  111. .buttonStyle(CommentSecondaryButtonStyle())
  112. .disabled(!viewModel.canExport)
  113. Button {
  114. guard requirePremiumAccess() else { return }
  115. Task { await viewModel.generateComment() }
  116. } label: {
  117. HStack(spacing: 6) {
  118. if viewModel.isGenerating {
  119. ProgressView()
  120. .controlSize(.small)
  121. .tint(.white)
  122. } else {
  123. Image(systemName: "sparkles")
  124. .font(.system(size: 11, weight: .semibold))
  125. }
  126. Text(viewModel.isGenerating ? "Generating…" : "Generate")
  127. .font(.system(size: 11, weight: .semibold))
  128. }
  129. .foregroundStyle(.white)
  130. .padding(.horizontal, 14)
  131. .padding(.vertical, 8)
  132. .background(viewModel.canGenerate ? AppTheme.accentGreen : AppTheme.accentGreen.opacity(0.4))
  133. .clipShape(RoundedRectangle(cornerRadius: 8))
  134. }
  135. .buttonStyle(.plain)
  136. .disabled(!viewModel.canGenerate)
  137. }
  138. }
  139. // MARK: - Tab Bar
  140. private var tabBar: some View {
  141. HStack(spacing: 4) {
  142. ForEach(CommentWriterTab.allCases) { tab in
  143. Button {
  144. viewModel.selectedTab = tab
  145. } label: {
  146. HStack(spacing: 4) {
  147. Text(tab.title)
  148. .font(.system(size: 11, weight: .semibold))
  149. if tab == .variants, !viewModel.variants.isEmpty {
  150. Text("\(viewModel.variants.count)")
  151. .font(.system(size: 9, weight: .bold))
  152. .foregroundStyle(.white)
  153. .padding(.horizontal, 5)
  154. .padding(.vertical, 2)
  155. .background(AppTheme.accentGreen)
  156. .clipShape(Capsule())
  157. }
  158. }
  159. .foregroundStyle(viewModel.selectedTab == tab ? AppTheme.textPrimary : AppTheme.textSecondary)
  160. .padding(.horizontal, 14)
  161. .padding(.vertical, 8)
  162. .background(
  163. viewModel.selectedTab == tab
  164. ? AppTheme.cardBackground
  165. : Color.clear
  166. )
  167. .clipShape(RoundedRectangle(cornerRadius: 8))
  168. }
  169. .buttonStyle(.plain)
  170. }
  171. Spacer()
  172. }
  173. .padding(.horizontal, 24)
  174. .padding(.top, 12)
  175. .padding(.bottom, 4)
  176. }
  177. // MARK: - Compose
  178. private var composeContent: some View {
  179. ViewThatFits(in: .horizontal) {
  180. HStack(alignment: .top, spacing: 16) {
  181. configurationPanel
  182. editorPanel
  183. }
  184. VStack(alignment: .leading, spacing: 16) {
  185. configurationPanel
  186. editorPanel
  187. }
  188. }
  189. }
  190. private var configurationPanel: some View {
  191. VStack(spacing: 12) {
  192. PostFormCard(title: "Comment Type", subtitle: "Top-level or reply to a comment") {
  193. CommentTypePicker(selectedType: Binding(
  194. get: { viewModel.draft.commentType },
  195. set: { viewModel.selectCommentType($0) }
  196. ))
  197. }
  198. PostFormCard(title: "Thread Context", subtitle: "What are you commenting on?") {
  199. VStack(alignment: .leading, spacing: 12) {
  200. PostFormField(
  201. label: "Subreddit",
  202. placeholder: "technology",
  203. text: $viewModel.draft.subreddit,
  204. prefix: "r/"
  205. )
  206. if !viewModel.draft.subreddit.isEmpty,
  207. !PostDraftValidator.isValidSubreddit(viewModel.draft.subreddit) {
  208. Text("Use 3–21 characters: letters, numbers, underscores only.")
  209. .font(.system(size: 9))
  210. .foregroundStyle(Color(hex: 0xF87171))
  211. }
  212. PostFormField(
  213. label: "Post Title",
  214. placeholder: "Paste or type the post title",
  215. text: $viewModel.draft.postTitle
  216. )
  217. PostFormTextEditor(
  218. label: "Post Body (optional)",
  219. placeholder: "Excerpt from the post for better context…",
  220. text: $viewModel.draft.postBody,
  221. minHeight: 70
  222. )
  223. if viewModel.draft.commentType == .reply {
  224. PostFormTextEditor(
  225. label: "Parent Comment",
  226. placeholder: "Paste the comment you're replying to…",
  227. text: $viewModel.draft.parentComment,
  228. minHeight: 90
  229. )
  230. }
  231. PostFormField(
  232. label: "Post URL (optional)",
  233. placeholder: "https://reddit.com/r/…/comments/…",
  234. text: $viewModel.draft.postURL
  235. )
  236. if !viewModel.draft.postURL.isEmpty,
  237. !CommentDraftValidator.isValidRedditURL(viewModel.draft.postURL) {
  238. Text("Enter a valid reddit.com URL to open the thread.")
  239. .font(.system(size: 9))
  240. .foregroundStyle(Color(hex: 0xF87171))
  241. }
  242. }
  243. }
  244. PostFormCard(title: "Comment Intent", subtitle: "What should this comment achieve?") {
  245. CommentIntentPicker(selectedIntent: $viewModel.draft.intent)
  246. }
  247. PostFormCard(title: "AI Settings", subtitle: "Tone, length, and style") {
  248. VStack(alignment: .leading, spacing: 12) {
  249. PostFormField(
  250. label: "Focus / Angle (optional)",
  251. placeholder: "e.g. share personal experience",
  252. text: $viewModel.draft.topic
  253. )
  254. VStack(alignment: .leading, spacing: 6) {
  255. Text("Tone")
  256. .font(.system(size: 10, weight: .medium))
  257. .foregroundStyle(AppTheme.textTertiary)
  258. CommentTonePicker(selectedTone: $viewModel.draft.tone)
  259. }
  260. VStack(alignment: .leading, spacing: 6) {
  261. Text("Length")
  262. .font(.system(size: 10, weight: .medium))
  263. .foregroundStyle(AppTheme.textTertiary)
  264. CommentLengthPicker(selectedLength: $viewModel.draft.length)
  265. }
  266. CommentToggleRow(
  267. title: "Use Markdown",
  268. subtitle: "Bold, italic, quotes, links",
  269. isOn: $viewModel.draft.useMarkdown
  270. )
  271. if viewModel.draft.commentType == .reply {
  272. CommentToggleRow(
  273. title: "Quote Parent",
  274. subtitle: "Include > blockquote of parent comment",
  275. isOn: $viewModel.draft.includeQuote
  276. )
  277. }
  278. VStack(alignment: .leading, spacing: 6) {
  279. Text("Variants")
  280. .font(.system(size: 10, weight: .medium))
  281. .foregroundStyle(AppTheme.textTertiary)
  282. CommentVariantCountPicker(selectedCount: $viewModel.draft.variantCount)
  283. }
  284. }
  285. }
  286. RedditCommentEtiquetteCard()
  287. }
  288. .frame(minWidth: 280, idealWidth: 300, maxWidth: 320)
  289. }
  290. private var editorPanel: some View {
  291. VStack(spacing: 12) {
  292. PostFormCard(
  293. title: "Your Comment",
  294. subtitle: viewModel.draft.useMarkdown
  295. ? "Markdown supported — edit after generating"
  296. : "Plain text — edit after generating"
  297. ) {
  298. PostFormTextEditor(
  299. label: "Comment Body",
  300. placeholder: "Generate a comment or write your own…",
  301. text: $viewModel.draft.body,
  302. minHeight: 220
  303. )
  304. }
  305. characterCountFooter
  306. RedditThreadContextCard(
  307. postTitle: viewModel.draft.postTitle,
  308. postBody: viewModel.draft.postBody,
  309. parentComment: viewModel.draft.parentComment,
  310. commentType: viewModel.draft.commentType,
  311. formattedSubreddit: viewModel.formattedSubreddit
  312. )
  313. }
  314. .frame(maxWidth: .infinity)
  315. }
  316. private var characterCountFooter: some View {
  317. HStack {
  318. Text("\(viewModel.draft.body.count) / \(CommentDraftValidator.maxCommentLength)")
  319. .font(.system(size: 10))
  320. .foregroundStyle(
  321. viewModel.draft.body.count > CommentDraftValidator.maxCommentLength
  322. ? Color(hex: 0xF87171)
  323. : AppTheme.textTertiary
  324. )
  325. Spacer()
  326. Text("Reddit limit: 10,000 characters")
  327. .font(.system(size: 10))
  328. .foregroundStyle(AppTheme.textTertiary)
  329. }
  330. }
  331. // MARK: - Preview
  332. private var previewContent: some View {
  333. HStack {
  334. Spacer(minLength: 0)
  335. VStack(spacing: 16) {
  336. Text("Live Preview")
  337. .font(.system(size: 12, weight: .semibold))
  338. .foregroundStyle(AppTheme.textSecondary)
  339. .frame(maxWidth: .infinity, alignment: .leading)
  340. RedditCommentPreviewCard(
  341. commentBody: viewModel.activeCommentBody,
  342. formattedSubreddit: viewModel.formattedSubreddit,
  343. commentType: viewModel.draft.commentType,
  344. parentComment: viewModel.draft.parentComment
  345. )
  346. previewMetadata
  347. }
  348. .frame(maxWidth: 560)
  349. Spacer(minLength: 0)
  350. }
  351. }
  352. private var previewMetadata: some View {
  353. VStack(alignment: .leading, spacing: 8) {
  354. metadataRow(label: "Type", value: viewModel.draft.commentType.title)
  355. metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
  356. metadataRow(label: "Intent", value: viewModel.draft.intent.title)
  357. metadataRow(label: "Tone", value: viewModel.draft.tone.title)
  358. metadataRow(label: "Length", value: viewModel.draft.length.title)
  359. metadataRow(label: "Engine", value: viewModel.usesLiveAI ? "OpenAI" : "Local templates")
  360. if let best = viewModel.bestVariant {
  361. metadataRow(label: "Best Score", value: "\(best.score)/100")
  362. }
  363. }
  364. .padding(14)
  365. .frame(maxWidth: .infinity, alignment: .leading)
  366. .background(
  367. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  368. .fill(AppTheme.cardBackground)
  369. .overlay(
  370. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  371. .stroke(AppTheme.cardBorder, lineWidth: 1)
  372. )
  373. )
  374. }
  375. // MARK: - Variants
  376. private var variantsContent: some View {
  377. VStack(spacing: 16) {
  378. if viewModel.variants.isEmpty {
  379. CommentWriterEmptyState(
  380. icon: "bubble.left.and.bubble.right",
  381. title: "No variants yet",
  382. subtitle: "Fill in thread context on the Compose tab,\nthen tap Generate to get comment variants."
  383. )
  384. } else {
  385. variantsHeader
  386. VStack(spacing: 8) {
  387. ForEach(viewModel.variants) { variant in
  388. CommentVariantRow(
  389. variant: variant,
  390. isSelected: viewModel.selectedVariantID == variant.id,
  391. onSelect: { viewModel.selectVariant(variant) },
  392. onApply: { viewModel.applyVariant(variant) }
  393. )
  394. }
  395. }
  396. if !viewModel.activeCommentBody.isEmpty {
  397. PostFormCard(title: "Selected Preview", subtitle: "How this variant looks in a thread") {
  398. RedditCommentPreviewCard(
  399. commentBody: viewModel.activeCommentBody,
  400. formattedSubreddit: viewModel.formattedSubreddit,
  401. commentType: viewModel.draft.commentType,
  402. parentComment: viewModel.draft.parentComment
  403. )
  404. }
  405. }
  406. }
  407. }
  408. .frame(maxWidth: 640)
  409. .frame(maxWidth: .infinity)
  410. }
  411. private var variantsHeader: some View {
  412. HStack {
  413. VStack(alignment: .leading, spacing: 2) {
  414. Text("Comment Variants")
  415. .font(.system(size: 12, weight: .semibold))
  416. .foregroundStyle(AppTheme.textPrimary)
  417. Text("Tap a variant to preview, or Apply to use it")
  418. .font(.system(size: 10))
  419. .foregroundStyle(AppTheme.textSecondary)
  420. }
  421. Spacer()
  422. if let best = viewModel.bestVariant {
  423. HStack(spacing: 6) {
  424. Image(systemName: "trophy.fill")
  425. .font(.system(size: 10))
  426. .foregroundStyle(AppTheme.accentYellow)
  427. Text("Best: \(best.score)/100")
  428. .font(.system(size: 10, weight: .semibold))
  429. .foregroundStyle(AppTheme.textSecondary)
  430. }
  431. }
  432. }
  433. }
  434. private func metadataRow(label: String, value: String) -> some View {
  435. HStack(alignment: .top) {
  436. Text(label)
  437. .font(.system(size: 10, weight: .medium))
  438. .foregroundStyle(AppTheme.textTertiary)
  439. .frame(width: 70, alignment: .leading)
  440. Text(value)
  441. .font(.system(size: 10))
  442. .foregroundStyle(AppTheme.textSecondary)
  443. .fixedSize(horizontal: false, vertical: true)
  444. }
  445. }
  446. }
  447. #Preview {
  448. CommentWriterView(viewModel: CommentWriterViewModel()) { _ in }
  449. .frame(width: 880, height: 720)
  450. }