CommentWriterView.swift 19 KB

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