CommentWriterView.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  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: "Generated result", icon: "bubble.left.and.bubble.right") {
  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: "Generated result", icon: "bubble.left.and.bubble.right") {
  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. if viewModel.hasGeneratedContent {
  297. generatedContentSection
  298. } else {
  299. CommentWriterEmptyResultPlaceholder()
  300. }
  301. }
  302. }
  303. private var generatedContentSection: some View {
  304. VStack(spacing: Layout.sectionSpacing) {
  305. PostFormCard(
  306. title: "Comment",
  307. subtitle: viewModel.draft.useMarkdown
  308. ? "Generated by AI — markdown supported, edit if you like"
  309. : "Generated by AI — plain text, edit if you like"
  310. ) {
  311. PostFormTextEditor(
  312. label: "Comment Body",
  313. placeholder: "Edit the generated comment…",
  314. text: $viewModel.draft.body,
  315. minHeight: 190
  316. )
  317. }
  318. characterCountFooter
  319. RedditThreadContextCard(
  320. postTitle: viewModel.draft.postTitle,
  321. postBody: viewModel.draft.postBody,
  322. parentComment: viewModel.draft.parentComment,
  323. commentType: viewModel.draft.commentType,
  324. formattedSubreddit: viewModel.formattedSubreddit
  325. )
  326. }
  327. }
  328. private var characterCountFooter: some View {
  329. HStack(spacing: 12) {
  330. Text("\(viewModel.draft.body.count) / \(CommentDraftValidator.maxCommentLength)")
  331. .font(.system(size: 10, weight: .medium, design: .monospaced))
  332. .foregroundStyle(
  333. viewModel.draft.body.count > CommentDraftValidator.maxCommentLength
  334. ? Color(hex: 0xF87171)
  335. : AppTheme.textTertiary
  336. )
  337. Spacer()
  338. Text("Reddit limit: 10,000 characters")
  339. .font(.system(size: 10))
  340. .foregroundStyle(AppTheme.textTertiary)
  341. }
  342. .padding(.horizontal, 12)
  343. .padding(.vertical, 9)
  344. .background(
  345. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  346. .fill(AppTheme.cardBackground)
  347. .overlay(
  348. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  349. .stroke(AppTheme.cardBorder, lineWidth: 1)
  350. )
  351. )
  352. }
  353. // MARK: - Preview
  354. private var previewContent: some View {
  355. VStack(alignment: .leading, spacing: Layout.columnSpacing) {
  356. sectionHeader(title: "Live Preview", icon: "eye")
  357. HStack {
  358. Spacer(minLength: 0)
  359. VStack(spacing: Layout.sectionSpacing) {
  360. RedditCommentPreviewCard(
  361. commentBody: viewModel.activeCommentBody,
  362. formattedSubreddit: viewModel.formattedSubreddit,
  363. commentType: viewModel.draft.commentType,
  364. parentComment: viewModel.draft.parentComment
  365. )
  366. previewMetadata
  367. }
  368. .frame(maxWidth: 560)
  369. Spacer(minLength: 0)
  370. }
  371. }
  372. }
  373. private var previewMetadata: some View {
  374. VStack(alignment: .leading, spacing: 0) {
  375. metadataRow(label: "Type", value: viewModel.draft.commentType.title)
  376. metadataDivider
  377. metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
  378. metadataDivider
  379. metadataRow(label: "Intent", value: viewModel.draft.intent.title)
  380. metadataDivider
  381. metadataRow(label: "Tone", value: viewModel.draft.tone.title)
  382. metadataDivider
  383. metadataRow(label: "Length", value: viewModel.draft.length.title)
  384. metadataDivider
  385. metadataRow(label: "Engine", value: viewModel.usesLiveAI ? "AI" : "Local templates")
  386. if let best = viewModel.bestVariant {
  387. metadataDivider
  388. metadataRow(label: "Best Score", value: "\(best.score)/100")
  389. }
  390. }
  391. .padding(14)
  392. .frame(maxWidth: .infinity, alignment: .leading)
  393. .background(
  394. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  395. .fill(AppTheme.cardBackground)
  396. .overlay(
  397. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  398. .stroke(AppTheme.cardBorder, lineWidth: 1)
  399. )
  400. )
  401. }
  402. // MARK: - Variants
  403. private var variantsContent: some View {
  404. VStack(alignment: .leading, spacing: Layout.columnSpacing) {
  405. variantsHeader
  406. if viewModel.variants.isEmpty {
  407. CommentWriterEmptyState(
  408. icon: "bubble.left.and.bubble.right",
  409. title: "No variants yet",
  410. subtitle: "Fill in thread context on the Compose tab,\nthen tap Generate to get comment variants."
  411. )
  412. .frame(maxWidth: .infinity)
  413. } else {
  414. variantsBody
  415. }
  416. }
  417. }
  418. private var variantsBody: some View {
  419. VStack(spacing: Layout.sectionSpacing) {
  420. VStack(spacing: 8) {
  421. ForEach(viewModel.variants) { variant in
  422. CommentVariantRow(
  423. variant: variant,
  424. isSelected: viewModel.selectedVariantID == variant.id,
  425. onSelect: { viewModel.selectVariant(variant) },
  426. onApply: { viewModel.applyVariant(variant) }
  427. )
  428. }
  429. }
  430. if !viewModel.activeCommentBody.isEmpty {
  431. PostFormCard(title: "Selected Preview", subtitle: "How this variant looks in a thread") {
  432. RedditCommentPreviewCard(
  433. commentBody: viewModel.activeCommentBody,
  434. formattedSubreddit: viewModel.formattedSubreddit,
  435. commentType: viewModel.draft.commentType,
  436. parentComment: viewModel.draft.parentComment
  437. )
  438. }
  439. }
  440. }
  441. .frame(maxWidth: 640)
  442. .frame(maxWidth: .infinity)
  443. }
  444. private var variantsHeader: some View {
  445. HStack(alignment: .top) {
  446. sectionHeader(title: "Comment Variants", icon: "bubble.left.and.bubble.right")
  447. Spacer()
  448. if let best = viewModel.bestVariant {
  449. HStack(spacing: 6) {
  450. Image(systemName: "trophy.fill")
  451. .font(.system(size: 10))
  452. .foregroundStyle(AppTheme.accentYellow)
  453. Text("Best: \(best.score)/100")
  454. .font(.system(size: 10, weight: .semibold))
  455. .foregroundStyle(AppTheme.textSecondary)
  456. }
  457. .padding(.top, 1)
  458. }
  459. }
  460. }
  461. private var metadataDivider: some View {
  462. Rectangle()
  463. .fill(AppTheme.border)
  464. .frame(height: 1)
  465. .padding(.vertical, 6)
  466. }
  467. private func metadataRow(label: String, value: String) -> some View {
  468. HStack(alignment: .top, spacing: 12) {
  469. Text(label)
  470. .font(.system(size: 10, weight: .medium))
  471. .foregroundStyle(AppTheme.textTertiary)
  472. .frame(width: 72, alignment: .leading)
  473. Text(value)
  474. .font(.system(size: 10))
  475. .foregroundStyle(AppTheme.textSecondary)
  476. .fixedSize(horizontal: false, vertical: true)
  477. }
  478. }
  479. private func validationHint(_ message: String) -> some View {
  480. HStack(spacing: 4) {
  481. Image(systemName: "exclamationmark.circle.fill")
  482. .font(.system(size: 9))
  483. Text(message)
  484. .font(.system(size: 9))
  485. }
  486. .foregroundStyle(Color(hex: 0xF87171))
  487. }
  488. }
  489. #Preview {
  490. CommentWriterView(viewModel: CommentWriterViewModel())
  491. .frame(width: 880, height: 720)
  492. }