CommentWriterView.swift 20 KB

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