CommentWriterView.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  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. isRequired: true
  212. )
  213. if !viewModel.draft.subreddit.isEmpty,
  214. !PostDraftValidator.isValidSubreddit(viewModel.draft.subreddit) {
  215. validationHint("Use 3–21 characters: letters, numbers, underscores only.")
  216. }
  217. PostFormField(
  218. label: "Post Title",
  219. placeholder: "Paste or type the post title",
  220. text: $viewModel.draft.postTitle,
  221. isRequired: true
  222. )
  223. PostFormTextEditor(
  224. label: "Post Body (optional)",
  225. placeholder: "Excerpt from the post for better context…",
  226. text: $viewModel.draft.postBody,
  227. minHeight: 70
  228. )
  229. if viewModel.draft.commentType == .reply {
  230. PostFormTextEditor(
  231. label: "Parent Comment",
  232. placeholder: "Paste the comment you're replying to…",
  233. text: $viewModel.draft.parentComment,
  234. minHeight: 90,
  235. isRequired: true
  236. )
  237. }
  238. PostFormField(
  239. label: "Post URL (optional)",
  240. placeholder: "https://reddit.com/r/…/comments/…",
  241. text: $viewModel.draft.postURL
  242. )
  243. if !viewModel.draft.postURL.isEmpty,
  244. !CommentDraftValidator.isValidRedditURL(viewModel.draft.postURL) {
  245. validationHint("Enter a valid reddit.com URL to open the thread.")
  246. }
  247. }
  248. }
  249. PostFormCard(title: "Comment Intent", subtitle: "What should this comment achieve?") {
  250. CommentIntentPicker(selectedIntent: $viewModel.draft.intent)
  251. }
  252. PostFormCard(title: "AI Settings", subtitle: "Tone, length, and style") {
  253. VStack(alignment: .leading, spacing: 12) {
  254. PostFormField(
  255. label: "Focus / Angle (optional)",
  256. placeholder: "e.g. share personal experience",
  257. text: $viewModel.draft.topic
  258. )
  259. VStack(alignment: .leading, spacing: 6) {
  260. Text("Tone")
  261. .font(.system(size: 10, weight: .medium))
  262. .foregroundStyle(AppTheme.textTertiary)
  263. CommentTonePicker(selectedTone: $viewModel.draft.tone)
  264. }
  265. VStack(alignment: .leading, spacing: 6) {
  266. Text("Length")
  267. .font(.system(size: 10, weight: .medium))
  268. .foregroundStyle(AppTheme.textTertiary)
  269. CommentLengthPicker(selectedLength: $viewModel.draft.length)
  270. }
  271. CommentToggleRow(
  272. title: "Use Markdown",
  273. subtitle: "Bold, italic, quotes, links",
  274. isOn: $viewModel.draft.useMarkdown
  275. )
  276. if viewModel.draft.commentType == .reply {
  277. CommentToggleRow(
  278. title: "Quote Parent",
  279. subtitle: "Include > blockquote of parent comment",
  280. isOn: $viewModel.draft.includeQuote
  281. )
  282. }
  283. VStack(alignment: .leading, spacing: 6) {
  284. Text("Variants")
  285. .font(.system(size: 10, weight: .medium))
  286. .foregroundStyle(AppTheme.textTertiary)
  287. CommentVariantCountPicker(selectedCount: $viewModel.draft.variantCount)
  288. }
  289. }
  290. }
  291. RedditCommentEtiquetteCard()
  292. }
  293. }
  294. private var editorPanel: some View {
  295. VStack(spacing: Layout.sectionSpacing) {
  296. PostFormCard(
  297. title: "Your Comment",
  298. subtitle: viewModel.draft.useMarkdown
  299. ? "Markdown supported — edit after generating"
  300. : "Plain text — edit after generating"
  301. ) {
  302. PostFormTextEditor(
  303. label: "Comment Body",
  304. placeholder: "Generate a comment or write your own…",
  305. text: $viewModel.draft.body,
  306. minHeight: 190
  307. )
  308. }
  309. characterCountFooter
  310. RedditThreadContextCard(
  311. postTitle: viewModel.draft.postTitle,
  312. postBody: viewModel.draft.postBody,
  313. parentComment: viewModel.draft.parentComment,
  314. commentType: viewModel.draft.commentType,
  315. formattedSubreddit: viewModel.formattedSubreddit
  316. )
  317. }
  318. }
  319. private var characterCountFooter: some View {
  320. HStack(spacing: 12) {
  321. Text("\(viewModel.draft.body.count) / \(CommentDraftValidator.maxCommentLength)")
  322. .font(.system(size: 10, weight: .medium, design: .monospaced))
  323. .foregroundStyle(
  324. viewModel.draft.body.count > CommentDraftValidator.maxCommentLength
  325. ? Color(hex: 0xF87171)
  326. : AppTheme.textTertiary
  327. )
  328. Spacer()
  329. Text("Reddit limit: 10,000 characters")
  330. .font(.system(size: 10))
  331. .foregroundStyle(AppTheme.textTertiary)
  332. }
  333. .padding(.horizontal, 12)
  334. .padding(.vertical, 9)
  335. .background(
  336. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  337. .fill(AppTheme.cardBackground)
  338. .overlay(
  339. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  340. .stroke(AppTheme.cardBorder, lineWidth: 1)
  341. )
  342. )
  343. }
  344. // MARK: - Preview
  345. private var previewContent: some View {
  346. VStack(alignment: .leading, spacing: Layout.columnSpacing) {
  347. sectionHeader(title: "Live Preview", icon: "eye")
  348. HStack {
  349. Spacer(minLength: 0)
  350. VStack(spacing: Layout.sectionSpacing) {
  351. RedditCommentPreviewCard(
  352. commentBody: viewModel.activeCommentBody,
  353. formattedSubreddit: viewModel.formattedSubreddit,
  354. commentType: viewModel.draft.commentType,
  355. parentComment: viewModel.draft.parentComment
  356. )
  357. previewMetadata
  358. }
  359. .frame(maxWidth: 560)
  360. Spacer(minLength: 0)
  361. }
  362. }
  363. }
  364. private var previewMetadata: some View {
  365. VStack(alignment: .leading, spacing: 0) {
  366. metadataRow(label: "Type", value: viewModel.draft.commentType.title)
  367. metadataDivider
  368. metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
  369. metadataDivider
  370. metadataRow(label: "Intent", value: viewModel.draft.intent.title)
  371. metadataDivider
  372. metadataRow(label: "Tone", value: viewModel.draft.tone.title)
  373. metadataDivider
  374. metadataRow(label: "Length", value: viewModel.draft.length.title)
  375. metadataDivider
  376. metadataRow(label: "Engine", value: viewModel.usesLiveAI ? "AI" : "Local templates")
  377. if let best = viewModel.bestVariant {
  378. metadataDivider
  379. metadataRow(label: "Best Score", value: "\(best.score)/100")
  380. }
  381. }
  382. .padding(14)
  383. .frame(maxWidth: .infinity, alignment: .leading)
  384. .background(
  385. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  386. .fill(AppTheme.cardBackground)
  387. .overlay(
  388. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  389. .stroke(AppTheme.cardBorder, lineWidth: 1)
  390. )
  391. )
  392. }
  393. // MARK: - Variants
  394. private var variantsContent: some View {
  395. VStack(alignment: .leading, spacing: Layout.columnSpacing) {
  396. variantsHeader
  397. if viewModel.variants.isEmpty {
  398. CommentWriterEmptyState(
  399. icon: "bubble.left.and.bubble.right",
  400. title: "No variants yet",
  401. subtitle: "Fill in thread context on the Compose tab,\nthen tap Generate to get comment variants."
  402. )
  403. .frame(maxWidth: .infinity)
  404. } else {
  405. variantsBody
  406. }
  407. }
  408. }
  409. private var variantsBody: some View {
  410. VStack(spacing: Layout.sectionSpacing) {
  411. VStack(spacing: 8) {
  412. ForEach(viewModel.variants) { variant in
  413. CommentVariantRow(
  414. variant: variant,
  415. isSelected: viewModel.selectedVariantID == variant.id,
  416. onSelect: { viewModel.selectVariant(variant) },
  417. onApply: { viewModel.applyVariant(variant) }
  418. )
  419. }
  420. }
  421. if !viewModel.activeCommentBody.isEmpty {
  422. PostFormCard(title: "Selected Preview", subtitle: "How this variant looks in a thread") {
  423. RedditCommentPreviewCard(
  424. commentBody: viewModel.activeCommentBody,
  425. formattedSubreddit: viewModel.formattedSubreddit,
  426. commentType: viewModel.draft.commentType,
  427. parentComment: viewModel.draft.parentComment
  428. )
  429. }
  430. }
  431. }
  432. .frame(maxWidth: 640)
  433. .frame(maxWidth: .infinity)
  434. }
  435. private var variantsHeader: some View {
  436. HStack(alignment: .top) {
  437. sectionHeader(title: "Comment Variants", icon: "bubble.left.and.bubble.right")
  438. Spacer()
  439. if let best = viewModel.bestVariant {
  440. HStack(spacing: 6) {
  441. Image(systemName: "trophy.fill")
  442. .font(.system(size: 10))
  443. .foregroundStyle(AppTheme.accentYellow)
  444. Text("Best: \(best.score)/100")
  445. .font(.system(size: 10, weight: .semibold))
  446. .foregroundStyle(AppTheme.textSecondary)
  447. }
  448. .padding(.top, 1)
  449. }
  450. }
  451. }
  452. private var metadataDivider: some View {
  453. Rectangle()
  454. .fill(AppTheme.border)
  455. .frame(height: 1)
  456. .padding(.vertical, 6)
  457. }
  458. private func metadataRow(label: String, value: String) -> some View {
  459. HStack(alignment: .top, spacing: 12) {
  460. Text(label)
  461. .font(.system(size: 10, weight: .medium))
  462. .foregroundStyle(AppTheme.textTertiary)
  463. .frame(width: 72, alignment: .leading)
  464. Text(value)
  465. .font(.system(size: 10))
  466. .foregroundStyle(AppTheme.textSecondary)
  467. .fixedSize(horizontal: false, vertical: true)
  468. }
  469. }
  470. private func validationHint(_ message: String) -> some View {
  471. HStack(spacing: 4) {
  472. Image(systemName: "exclamationmark.circle.fill")
  473. .font(.system(size: 9))
  474. Text(message)
  475. .font(.system(size: 9))
  476. }
  477. .foregroundStyle(Color(hex: 0xF87171))
  478. }
  479. }
  480. #Preview {
  481. CommentWriterView(viewModel: CommentWriterViewModel())
  482. .frame(width: 880, height: 720)
  483. }