CommentWriterComponents.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. import SwiftUI
  2. // MARK: - Pickers
  3. struct CommentTypePicker: View {
  4. @Binding var selectedType: CommentType
  5. var body: some View {
  6. HStack(spacing: 8) {
  7. ForEach(CommentType.allCases) { type in
  8. Button {
  9. selectedType = type
  10. } label: {
  11. HStack(spacing: 8) {
  12. Image(systemName: type.systemImage)
  13. .font(.system(size: 12, weight: .semibold))
  14. .foregroundStyle(
  15. selectedType == type ? AppTheme.accentGreen : AppTheme.textSecondary
  16. )
  17. .frame(width: 16)
  18. VStack(alignment: .leading, spacing: 1) {
  19. Text(type.title)
  20. .font(.system(size: 11, weight: .semibold))
  21. .foregroundStyle(AppTheme.textPrimary)
  22. Text(type.subtitle)
  23. .font(.system(size: 9))
  24. .foregroundStyle(AppTheme.textTertiary)
  25. .lineLimit(1)
  26. }
  27. Spacer(minLength: 0)
  28. }
  29. .frame(maxWidth: .infinity, alignment: .leading)
  30. .padding(.horizontal, 10)
  31. .padding(.vertical, 8)
  32. .background(
  33. RoundedRectangle(cornerRadius: 8)
  34. .fill(
  35. selectedType == type
  36. ? AppTheme.accentGreen.opacity(0.15)
  37. : AppTheme.panelBackground
  38. )
  39. )
  40. .overlay(
  41. RoundedRectangle(cornerRadius: 8)
  42. .stroke(
  43. selectedType == type
  44. ? AppTheme.accentGreen.opacity(0.5)
  45. : AppTheme.cardBorder,
  46. lineWidth: 1
  47. )
  48. )
  49. }
  50. .buttonStyle(AppPlainButtonStyle())
  51. .hoverOverlay(cornerRadius: 8)
  52. }
  53. }
  54. }
  55. }
  56. struct CommentIntentPicker: View {
  57. @Binding var selectedIntent: CommentIntent
  58. var body: some View {
  59. LazyVGrid(
  60. columns: [GridItem(.flexible()), GridItem(.flexible())],
  61. spacing: 8
  62. ) {
  63. ForEach(CommentIntent.allCases) { intent in
  64. Button {
  65. selectedIntent = intent
  66. } label: {
  67. HStack(spacing: 8) {
  68. Image(systemName: intent.systemImage)
  69. .font(.system(size: 11, weight: .semibold))
  70. .foregroundStyle(
  71. selectedIntent == intent ? AppTheme.accentGreen : AppTheme.textSecondary
  72. )
  73. .frame(width: 14)
  74. VStack(alignment: .leading, spacing: 1) {
  75. Text(intent.title)
  76. .font(.system(size: 11, weight: .semibold))
  77. .foregroundStyle(AppTheme.textPrimary)
  78. Text(intent.subtitle)
  79. .font(.system(size: 9))
  80. .foregroundStyle(AppTheme.textTertiary)
  81. .lineLimit(1)
  82. }
  83. Spacer(minLength: 0)
  84. }
  85. .padding(.horizontal, 10)
  86. .padding(.vertical, 8)
  87. .background(
  88. RoundedRectangle(cornerRadius: 8)
  89. .fill(
  90. selectedIntent == intent
  91. ? AppTheme.accentGreen.opacity(0.15)
  92. : AppTheme.panelBackground
  93. )
  94. )
  95. .overlay(
  96. RoundedRectangle(cornerRadius: 8)
  97. .stroke(
  98. selectedIntent == intent
  99. ? AppTheme.accentGreen.opacity(0.5)
  100. : AppTheme.cardBorder,
  101. lineWidth: 1
  102. )
  103. )
  104. }
  105. .buttonStyle(AppPlainButtonStyle())
  106. .hoverOverlay(cornerRadius: 8)
  107. }
  108. }
  109. }
  110. }
  111. struct CommentLengthPicker: View {
  112. @Binding var selectedLength: CommentLength
  113. var body: some View {
  114. HStack(spacing: 8) {
  115. ForEach(CommentLength.allCases) { length in
  116. Button {
  117. selectedLength = length
  118. } label: {
  119. VStack(spacing: 2) {
  120. Text(length.title)
  121. .font(.system(size: 10, weight: .semibold))
  122. Text(length.wordRange)
  123. .font(.system(size: 8))
  124. .foregroundStyle(
  125. selectedLength == length ? .white.opacity(0.8) : AppTheme.textTertiary
  126. )
  127. }
  128. .foregroundStyle(selectedLength == length ? .white : AppTheme.textSecondary)
  129. .padding(.horizontal, 12)
  130. .padding(.vertical, 8)
  131. .frame(maxWidth: .infinity)
  132. .background(
  133. RoundedRectangle(cornerRadius: 8)
  134. .fill(
  135. selectedLength == length
  136. ? AppTheme.accentGreen
  137. : AppTheme.panelBackground
  138. )
  139. )
  140. .overlay(
  141. RoundedRectangle(cornerRadius: 8)
  142. .stroke(AppTheme.cardBorder, lineWidth: 1)
  143. )
  144. }
  145. .buttonStyle(AppPlainButtonStyle())
  146. .hoverOverlay(cornerRadius: 8)
  147. }
  148. }
  149. }
  150. }
  151. struct CommentTonePicker: View {
  152. @Binding var selectedTone: PostTone
  153. var body: some View {
  154. ScrollView(.horizontal, showsIndicators: false) {
  155. HStack(spacing: 6) {
  156. ForEach(PostTone.allCases) { tone in
  157. Button {
  158. selectedTone = tone
  159. } label: {
  160. Text(tone.title)
  161. .font(.system(size: 10, weight: .semibold))
  162. .foregroundStyle(selectedTone == tone ? .white : AppTheme.textSecondary)
  163. .padding(.horizontal, 10)
  164. .padding(.vertical, 6)
  165. .background(
  166. Capsule()
  167. .fill(
  168. selectedTone == tone
  169. ? AppTheme.accentGreen
  170. : AppTheme.panelBackground
  171. )
  172. )
  173. .overlay(
  174. Capsule()
  175. .stroke(
  176. selectedTone == tone
  177. ? AppTheme.accentGreen.opacity(0.5)
  178. : AppTheme.cardBorder,
  179. lineWidth: 1
  180. )
  181. )
  182. }
  183. .buttonStyle(AppPlainButtonStyle())
  184. .hoverOverlay(cornerRadius: 50)
  185. }
  186. }
  187. }
  188. }
  189. }
  190. struct CommentVariantCountPicker: View {
  191. @Binding var selectedCount: CommentVariantCount
  192. var body: some View {
  193. HStack(spacing: 8) {
  194. ForEach(CommentVariantCount.allCases) { count in
  195. Button {
  196. selectedCount = count
  197. } label: {
  198. Text(count.label)
  199. .font(.system(size: 10, weight: .semibold))
  200. .foregroundStyle(selectedCount == count ? .white : AppTheme.textSecondary)
  201. .frame(maxWidth: .infinity)
  202. .padding(.vertical, 7)
  203. .background(
  204. RoundedRectangle(cornerRadius: 7)
  205. .fill(
  206. selectedCount == count
  207. ? AppTheme.accentGreen
  208. : AppTheme.panelBackground
  209. )
  210. )
  211. .overlay(
  212. RoundedRectangle(cornerRadius: 7)
  213. .stroke(AppTheme.cardBorder, lineWidth: 1)
  214. )
  215. }
  216. .buttonStyle(AppPlainButtonStyle())
  217. .hoverOverlay(cornerRadius: 8)
  218. }
  219. }
  220. }
  221. }
  222. struct CommentToggleRow: View {
  223. let title: String
  224. let subtitle: String
  225. @Binding var isOn: Bool
  226. var body: some View {
  227. Toggle(isOn: $isOn) {
  228. VStack(alignment: .leading, spacing: 2) {
  229. Text(title)
  230. .font(.system(size: 11, weight: .medium))
  231. .foregroundStyle(AppTheme.textPrimary)
  232. Text(subtitle)
  233. .font(.system(size: 9))
  234. .foregroundStyle(AppTheme.textTertiary)
  235. }
  236. }
  237. .toggleStyle(.switch)
  238. .tint(AppTheme.accentGreen)
  239. .padding(.horizontal, 10)
  240. .padding(.vertical, 8)
  241. .hoverOverlay(cornerRadius: 8)
  242. .hoverCursor()
  243. }
  244. }
  245. // MARK: - Reddit Etiquette Card
  246. struct RedditCommentEtiquetteCard: View {
  247. var body: some View {
  248. VStack(alignment: .leading, spacing: 8) {
  249. HStack(spacing: 6) {
  250. Image(systemName: "info.circle.fill")
  251. .font(.system(size: 11))
  252. .foregroundStyle(AppTheme.accentGreen)
  253. Text("Reddit Comment Tips")
  254. .font(.system(size: 11, weight: .semibold))
  255. .foregroundStyle(AppTheme.textPrimary)
  256. }
  257. VStack(alignment: .leading, spacing: 4) {
  258. etiquetteRow("10,000 character limit per comment")
  259. etiquetteRow("Markdown: **bold**, *italic*, > quotes, `code`")
  260. etiquetteRow("Stay on-topic for the subreddit")
  261. etiquetteRow("Be civil — disagree with ideas, not people")
  262. etiquetteRow("Edit with \"Edit:\" to clarify, don't bait-and-switch")
  263. }
  264. }
  265. .padding(12)
  266. .frame(maxWidth: .infinity, alignment: .leading)
  267. .background(
  268. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  269. .fill(AppTheme.accentGreen.opacity(0.06))
  270. .overlay(
  271. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  272. .stroke(AppTheme.accentGreen.opacity(0.2), lineWidth: 1)
  273. )
  274. )
  275. }
  276. private func etiquetteRow(_ text: String) -> some View {
  277. HStack(alignment: .top, spacing: 6) {
  278. Text("•")
  279. .font(.system(size: 10))
  280. .foregroundStyle(AppTheme.accentGreen)
  281. Text(text)
  282. .font(.system(size: 10))
  283. .foregroundStyle(AppTheme.textSecondary)
  284. .fixedSize(horizontal: false, vertical: true)
  285. }
  286. }
  287. }
  288. // MARK: - Thread Context Card
  289. struct RedditThreadContextCard: View {
  290. let postTitle: String
  291. let postBody: String
  292. let parentComment: String
  293. let commentType: CommentType
  294. let formattedSubreddit: String
  295. var body: some View {
  296. VStack(alignment: .leading, spacing: 10) {
  297. HStack(spacing: 6) {
  298. Image(systemName: "doc.text")
  299. .font(.system(size: 11))
  300. .foregroundStyle(AppTheme.accentOrange)
  301. Text("Thread Context")
  302. .font(.system(size: 11, weight: .semibold))
  303. .foregroundStyle(AppTheme.textPrimary)
  304. }
  305. VStack(alignment: .leading, spacing: 8) {
  306. contextBlock(
  307. label: formattedSubreddit,
  308. content: postTitle.isEmpty ? "Post title" : postTitle,
  309. isTitle: true
  310. )
  311. if !postBody.isEmpty {
  312. contextBlock(label: "Post body", content: postBody, isTitle: false)
  313. }
  314. if commentType == .reply, !parentComment.isEmpty {
  315. contextBlock(label: "Replying to", content: parentComment, isTitle: false, isQuoted: true)
  316. }
  317. }
  318. }
  319. .padding(12)
  320. .frame(maxWidth: .infinity, alignment: .leading)
  321. .background(
  322. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  323. .fill(AppTheme.cardBackground)
  324. .overlay(
  325. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  326. .stroke(AppTheme.cardBorder, lineWidth: 1)
  327. )
  328. )
  329. }
  330. private func contextBlock(
  331. label: String,
  332. content: String,
  333. isTitle: Bool,
  334. isQuoted: Bool = false
  335. ) -> some View {
  336. VStack(alignment: .leading, spacing: 4) {
  337. Text(label)
  338. .font(.system(size: 9, weight: .semibold))
  339. .foregroundStyle(AppTheme.textTertiary)
  340. if isQuoted {
  341. HStack(spacing: 0) {
  342. Rectangle()
  343. .fill(AppTheme.accentGreen.opacity(0.5))
  344. .frame(width: 3)
  345. Text(content)
  346. .font(.system(size: 10))
  347. .foregroundStyle(AppTheme.textSecondary)
  348. .lineLimit(3)
  349. .padding(.leading, 8)
  350. }
  351. .padding(.vertical, 4)
  352. } else {
  353. Text(content)
  354. .font(.system(size: isTitle ? 11 : 10, weight: isTitle ? .semibold : .regular))
  355. .foregroundStyle(isTitle ? AppTheme.textPrimary : AppTheme.textSecondary)
  356. .lineLimit(isTitle ? 2 : 3)
  357. }
  358. }
  359. }
  360. }
  361. // MARK: - Comment Preview
  362. struct RedditCommentPreviewCard: View {
  363. let commentBody: String
  364. let formattedSubreddit: String
  365. let commentType: CommentType
  366. let parentComment: String
  367. var isReply: Bool = false
  368. var body: some View {
  369. VStack(alignment: .leading, spacing: 0) {
  370. if commentType == .reply, !parentComment.isEmpty {
  371. parentCommentRow
  372. .padding(.leading, 14)
  373. }
  374. commentRow
  375. .padding(.leading, commentType == .reply ? 28 : 0)
  376. }
  377. .background(AppTheme.panelBackground)
  378. .clipShape(RoundedRectangle(cornerRadius: AppTheme.cornerRadius))
  379. .overlay(
  380. RoundedRectangle(cornerRadius: AppTheme.cornerRadius)
  381. .stroke(AppTheme.cardBorder, lineWidth: 1)
  382. )
  383. }
  384. private var parentCommentRow: some View {
  385. HStack(alignment: .top, spacing: 8) {
  386. voteColumn
  387. VStack(alignment: .leading, spacing: 6) {
  388. commentHeader(username: "u/OriginalPoster")
  389. Text(parentComment)
  390. .font(.system(size: 11))
  391. .foregroundStyle(AppTheme.textSecondary)
  392. .lineLimit(3)
  393. .fixedSize(horizontal: false, vertical: true)
  394. commentActions
  395. }
  396. }
  397. .padding(.horizontal, 14)
  398. .padding(.vertical, 12)
  399. .opacity(0.7)
  400. }
  401. private var commentRow: some View {
  402. HStack(alignment: .top, spacing: 8) {
  403. voteColumn
  404. VStack(alignment: .leading, spacing: 6) {
  405. commentHeader(username: "u/ReddoraUser")
  406. if commentBody.isEmpty {
  407. Text("Your comment will appear here…")
  408. .font(.system(size: 12))
  409. .foregroundStyle(AppTheme.textTertiary)
  410. .italic()
  411. } else {
  412. PostMarkdownText(text: commentBody, fontSize: 12, color: AppTheme.textPrimary)
  413. }
  414. commentActions
  415. }
  416. }
  417. .padding(.horizontal, 14)
  418. .padding(.vertical, 12)
  419. .background(
  420. commentType == .reply
  421. ? AppTheme.accentGreen.opacity(0.04)
  422. : Color.clear
  423. )
  424. }
  425. private var voteColumn: some View {
  426. VStack(spacing: 2) {
  427. Image(systemName: "arrow.up")
  428. .font(.system(size: 12, weight: .semibold))
  429. .foregroundStyle(AppTheme.textTertiary)
  430. Text("1")
  431. .font(.system(size: 10, weight: .semibold))
  432. .foregroundStyle(AppTheme.textSecondary)
  433. Image(systemName: "arrow.down")
  434. .font(.system(size: 12, weight: .semibold))
  435. .foregroundStyle(AppTheme.textTertiary)
  436. }
  437. .frame(width: 24)
  438. }
  439. private func commentHeader(username: String) -> some View {
  440. HStack(spacing: 4) {
  441. Text(username)
  442. .font(.system(size: 10, weight: .semibold))
  443. .foregroundStyle(AppTheme.textPrimary)
  444. Text("·")
  445. .foregroundStyle(AppTheme.textTertiary)
  446. Text("just now")
  447. .font(.system(size: 9))
  448. .foregroundStyle(AppTheme.textTertiary)
  449. if commentType == .reply, username == "u/ReddoraUser" {
  450. Text("·")
  451. .foregroundStyle(AppTheme.textTertiary)
  452. Text(formattedSubreddit)
  453. .font(.system(size: 9, weight: .medium))
  454. .foregroundStyle(AppTheme.accentGreen)
  455. }
  456. }
  457. }
  458. private var commentActions: some View {
  459. HStack(spacing: 12) {
  460. actionButton("Reply", icon: "arrowshape.turn.up.left")
  461. actionButton("Share", icon: "square.and.arrow.up")
  462. actionButton("Award", icon: "seal")
  463. actionButton("⋯", icon: nil)
  464. }
  465. .padding(.top, 2)
  466. }
  467. private func actionButton(_ title: String, icon: String?) -> some View {
  468. HStack(spacing: 4) {
  469. if let icon {
  470. Image(systemName: icon)
  471. .font(.system(size: 9, weight: .semibold))
  472. }
  473. Text(title)
  474. .font(.system(size: 9, weight: .semibold))
  475. }
  476. .foregroundStyle(AppTheme.textTertiary)
  477. }
  478. }
  479. // MARK: - Variant Row
  480. struct CommentVariantRow: View {
  481. let variant: CommentVariant
  482. let isSelected: Bool
  483. let onSelect: () -> Void
  484. let onApply: () -> Void
  485. var body: some View {
  486. VStack(alignment: .leading, spacing: 8) {
  487. HStack {
  488. CommentScoreBadge(score: variant.score)
  489. Text(variant.reasoning)
  490. .font(.system(size: 9))
  491. .foregroundStyle(AppTheme.textTertiary)
  492. .lineLimit(1)
  493. Spacer()
  494. HStack(spacing: 6) {
  495. Button("Select", action: onSelect)
  496. .font(.system(size: 9, weight: .semibold))
  497. .foregroundStyle(isSelected ? AppTheme.accentGreen : AppTheme.textSecondary)
  498. .buttonStyle(AppPlainButtonStyle())
  499. .hoverOverlay(cornerRadius: 8)
  500. Button("Apply", action: onApply)
  501. .font(.system(size: 9, weight: .semibold))
  502. .foregroundStyle(.white)
  503. .padding(.horizontal, 8)
  504. .padding(.vertical, 4)
  505. .background(AppTheme.accentGreen.opacity(0.8))
  506. .clipShape(RoundedRectangle(cornerRadius: 5))
  507. .buttonStyle(AppPlainButtonStyle())
  508. .hoverOverlay(cornerRadius: 8)
  509. }
  510. }
  511. Text(variant.body)
  512. .font(.system(size: 11))
  513. .foregroundStyle(AppTheme.textSecondary)
  514. .lineLimit(4)
  515. .fixedSize(horizontal: false, vertical: true)
  516. }
  517. .padding(12)
  518. .frame(maxWidth: .infinity, alignment: .leading)
  519. .background(
  520. RoundedRectangle(cornerRadius: 8)
  521. .fill(isSelected ? AppTheme.accentGreen.opacity(0.1) : AppTheme.panelBackground)
  522. )
  523. .overlay(
  524. RoundedRectangle(cornerRadius: 8)
  525. .stroke(
  526. isSelected ? AppTheme.accentGreen.opacity(0.4) : AppTheme.cardBorder,
  527. lineWidth: 1
  528. )
  529. )
  530. .hoverCard(cornerRadius: 8, isSelected: isSelected)
  531. .hoverCursor()
  532. .onTapGesture(perform: onSelect)
  533. }
  534. }
  535. struct CommentScoreBadge: View {
  536. let score: Int
  537. var body: some View {
  538. Text("\(score)")
  539. .font(.system(size: 10, weight: .bold))
  540. .foregroundStyle(scoreColor)
  541. .padding(.horizontal, 6)
  542. .padding(.vertical, 3)
  543. .background(scoreColor.opacity(0.15))
  544. .clipShape(RoundedRectangle(cornerRadius: 4))
  545. }
  546. private var scoreColor: Color {
  547. switch score {
  548. case 80...: AppTheme.accentGreen
  549. case 60..<80: AppTheme.accentYellow
  550. default: AppTheme.textTertiary
  551. }
  552. }
  553. }
  554. struct CommentWriterEmptyState: View {
  555. let icon: String
  556. let title: String
  557. let subtitle: String
  558. var body: some View {
  559. VStack(spacing: 10) {
  560. Image(systemName: icon)
  561. .font(.system(size: 28))
  562. .foregroundStyle(AppTheme.accentGreen.opacity(0.6))
  563. Text(title)
  564. .font(.system(size: 12, weight: .semibold))
  565. .foregroundStyle(AppTheme.textPrimary)
  566. Text(subtitle)
  567. .font(.system(size: 10))
  568. .foregroundStyle(AppTheme.textSecondary)
  569. .multilineTextAlignment(.center)
  570. }
  571. .frame(maxWidth: .infinity)
  572. .padding(.vertical, 32)
  573. }
  574. }
  575. struct CommentSecondaryButtonStyle: ButtonStyle {
  576. func makeBody(configuration: Configuration) -> some View {
  577. AppSecondaryButtonStyle().makeBody(configuration: configuration)
  578. }
  579. }