PostGeneratorComponents.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. import AppKit
  2. import SwiftUI
  3. import UniformTypeIdentifiers
  4. // MARK: - Page Boundary
  5. struct PostPageBoundary<Content: View>: View {
  6. @ViewBuilder let content: Content
  7. var body: some View {
  8. content
  9. .padding(20)
  10. .background(
  11. RoundedRectangle(cornerRadius: AppTheme.cornerRadius)
  12. .fill(AppTheme.panelBackground)
  13. )
  14. .overlay(
  15. RoundedRectangle(cornerRadius: AppTheme.cornerRadius)
  16. .stroke(AppTheme.cardBorder, lineWidth: 1)
  17. )
  18. }
  19. }
  20. // MARK: - Shared Form Components
  21. struct PostFormLabel: View {
  22. let text: String
  23. var isRequired: Bool = false
  24. var body: some View {
  25. HStack(spacing: 2) {
  26. Text(text)
  27. .font(.system(size: 10, weight: .medium))
  28. .foregroundStyle(AppTheme.textTertiary)
  29. if isRequired {
  30. Text("*")
  31. .font(.system(size: 10, weight: .semibold))
  32. .foregroundStyle(Color(hex: 0xF87171))
  33. }
  34. }
  35. }
  36. }
  37. struct PostFormCard<Content: View>: View {
  38. let title: String
  39. var subtitle: String?
  40. @ViewBuilder let content: Content
  41. var body: some View {
  42. VStack(alignment: .leading, spacing: 12) {
  43. VStack(alignment: .leading, spacing: 2) {
  44. Text(title)
  45. .font(.system(size: 12, weight: .semibold))
  46. .foregroundStyle(AppTheme.textPrimary)
  47. if let subtitle {
  48. Text(subtitle)
  49. .font(.system(size: 10))
  50. .foregroundStyle(AppTheme.textSecondary)
  51. }
  52. }
  53. content
  54. }
  55. .padding(14)
  56. .frame(maxWidth: .infinity, alignment: .leading)
  57. .background(
  58. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  59. .fill(AppTheme.cardBackground)
  60. .overlay(
  61. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  62. .stroke(AppTheme.cardBorder, lineWidth: 1)
  63. )
  64. )
  65. }
  66. }
  67. struct PostFormField: View {
  68. let label: String
  69. var placeholder: String = ""
  70. @Binding var text: String
  71. var prefix: String?
  72. var isRequired: Bool = false
  73. var body: some View {
  74. VStack(alignment: .leading, spacing: 6) {
  75. PostFormLabel(text: label, isRequired: isRequired)
  76. HStack(spacing: 0) {
  77. if let prefix {
  78. Text(prefix)
  79. .font(.system(size: 12, weight: .medium))
  80. .foregroundStyle(AppTheme.textSecondary)
  81. .padding(.leading, 10)
  82. }
  83. TextField(placeholder, text: $text)
  84. .textFieldStyle(.plain)
  85. .font(.system(size: 12))
  86. .foregroundStyle(AppTheme.textPrimary)
  87. .padding(.horizontal, prefix == nil ? 10 : 4)
  88. .padding(.vertical, 8)
  89. }
  90. .background(AppTheme.panelBackground)
  91. .clipShape(RoundedRectangle(cornerRadius: 8))
  92. .overlay(
  93. RoundedRectangle(cornerRadius: 8)
  94. .stroke(AppTheme.cardBorder, lineWidth: 1)
  95. )
  96. }
  97. }
  98. }
  99. struct PostFormTextEditor: View {
  100. let label: String
  101. var placeholder: String = ""
  102. @Binding var text: String
  103. var minHeight: CGFloat = 120
  104. var isRequired: Bool = false
  105. var body: some View {
  106. VStack(alignment: .leading, spacing: 6) {
  107. PostFormLabel(text: label, isRequired: isRequired)
  108. ThinCaretTextEditor(
  109. text: $text,
  110. placeholder: placeholder
  111. )
  112. .frame(height: minHeight)
  113. .background(AppTheme.panelBackground)
  114. .clipShape(RoundedRectangle(cornerRadius: 8))
  115. .overlay(
  116. RoundedRectangle(cornerRadius: 8)
  117. .stroke(AppTheme.cardBorder, lineWidth: 1)
  118. )
  119. }
  120. }
  121. }
  122. struct PostTypePicker: View {
  123. @Binding var selectedType: RedditPostType
  124. var body: some View {
  125. LazyVGrid(
  126. columns: [GridItem(.flexible()), GridItem(.flexible())],
  127. spacing: 8
  128. ) {
  129. ForEach(RedditPostType.allCases) { type in
  130. PostTypeButton(type: type, isSelected: selectedType == type) {
  131. selectedType = type
  132. }
  133. }
  134. }
  135. }
  136. }
  137. private struct PostTypeButton: View {
  138. let type: RedditPostType
  139. let isSelected: Bool
  140. let action: () -> Void
  141. var body: some View {
  142. Button(action: action) {
  143. HStack(spacing: 8) {
  144. Image(systemName: type.systemImage)
  145. .font(.system(size: 12, weight: .semibold))
  146. .foregroundStyle(isSelected ? AppTheme.accentPurpleLight : AppTheme.textSecondary)
  147. .frame(width: 16)
  148. VStack(alignment: .leading, spacing: 1) {
  149. Text(type.title)
  150. .font(.system(size: 11, weight: .semibold))
  151. .foregroundStyle(AppTheme.textPrimary)
  152. Text(type.subtitle)
  153. .font(.system(size: 9))
  154. .foregroundStyle(AppTheme.textTertiary)
  155. .lineLimit(1)
  156. }
  157. Spacer(minLength: 0)
  158. }
  159. .padding(.horizontal, 10)
  160. .padding(.vertical, 8)
  161. .background(
  162. RoundedRectangle(cornerRadius: 8)
  163. .fill(isSelected ? AppTheme.accentPurple.opacity(0.15) : AppTheme.panelBackground)
  164. )
  165. .overlay(
  166. RoundedRectangle(cornerRadius: 8)
  167. .stroke(
  168. isSelected ? AppTheme.accentPurple.opacity(0.5) : AppTheme.cardBorder,
  169. lineWidth: 1
  170. )
  171. )
  172. }
  173. .buttonStyle(AppPlainButtonStyle())
  174. .hoverOverlay(cornerRadius: 8)
  175. }
  176. }
  177. struct PostTagToggle: View {
  178. let title: String
  179. let systemImage: String
  180. let activeColor: Color
  181. @Binding var isOn: Bool
  182. var body: some View {
  183. Button {
  184. isOn.toggle()
  185. } label: {
  186. HStack(spacing: 5) {
  187. Image(systemName: systemImage)
  188. .font(.system(size: 10, weight: .semibold))
  189. Text(title)
  190. .font(.system(size: 10, weight: .semibold))
  191. }
  192. .frame(maxWidth: .infinity)
  193. .foregroundStyle(isOn ? activeColor : AppTheme.textSecondary)
  194. .padding(.horizontal, 10)
  195. .padding(.vertical, 7)
  196. .background(
  197. RoundedRectangle(cornerRadius: 6)
  198. .fill(isOn ? activeColor.opacity(0.15) : AppTheme.panelBackground)
  199. )
  200. .overlay(
  201. RoundedRectangle(cornerRadius: 6)
  202. .stroke(isOn ? activeColor.opacity(0.4) : AppTheme.cardBorder, lineWidth: 1)
  203. )
  204. }
  205. .buttonStyle(AppPlainButtonStyle())
  206. .hoverOverlay(cornerRadius: 8)
  207. }
  208. }
  209. struct TonePicker: View {
  210. @Binding var selectedTone: PostTone
  211. var body: some View {
  212. ScrollView(.horizontal, showsIndicators: false) {
  213. HStack(spacing: 6) {
  214. ForEach(PostTone.allCases) { tone in
  215. Button {
  216. selectedTone = tone
  217. } label: {
  218. Text(tone.title)
  219. .font(.system(size: 10, weight: .semibold))
  220. .foregroundStyle(selectedTone == tone ? .white : AppTheme.textSecondary)
  221. .padding(.horizontal, 10)
  222. .padding(.vertical, 6)
  223. .background(
  224. Capsule()
  225. .fill(selectedTone == tone ? AppTheme.accentPurple : AppTheme.panelBackground)
  226. )
  227. .overlay(
  228. Capsule()
  229. .stroke(
  230. selectedTone == tone ? AppTheme.accentPurple.opacity(0.5) : AppTheme.cardBorder,
  231. lineWidth: 1
  232. )
  233. )
  234. }
  235. .buttonStyle(AppPlainButtonStyle())
  236. .hoverOverlay(cornerRadius: 50)
  237. }
  238. }
  239. }
  240. }
  241. }
  242. struct PostGeneratorMessageBanner: View {
  243. let message: String
  244. let isError: Bool
  245. var body: some View {
  246. HStack(spacing: 8) {
  247. Image(systemName: isError ? "exclamationmark.circle.fill" : "checkmark.circle.fill")
  248. .font(.system(size: 12))
  249. Text(message)
  250. .font(.system(size: 11))
  251. }
  252. .foregroundStyle(isError ? Color(hex: 0xF87171) : AppTheme.accentGreen)
  253. .padding(.horizontal, 12)
  254. .padding(.vertical, 8)
  255. .frame(maxWidth: .infinity, alignment: .leading)
  256. .background(
  257. RoundedRectangle(cornerRadius: 8)
  258. .fill((isError ? Color(hex: 0xF87171) : AppTheme.accentGreen).opacity(0.1))
  259. )
  260. }
  261. }
  262. struct PostGeneratorEmptyResultPlaceholder: View {
  263. var body: some View {
  264. VStack(spacing: 12) {
  265. Image(systemName: "sparkles")
  266. .font(.system(size: 28, weight: .medium))
  267. .foregroundStyle(AppTheme.accentPurpleLight)
  268. VStack(spacing: 6) {
  269. Text("Fill Configuration, then press Generate")
  270. .font(.system(size: 13, weight: .semibold))
  271. .foregroundStyle(AppTheme.textPrimary)
  272. .multilineTextAlignment(.center)
  273. Text("AI writes the title and body from your Configuration. You don't need to fill anything here first.")
  274. .font(.system(size: 11))
  275. .foregroundStyle(AppTheme.textSecondary)
  276. .multilineTextAlignment(.center)
  277. .fixedSize(horizontal: false, vertical: true)
  278. }
  279. }
  280. .padding(.horizontal, 24)
  281. .padding(.vertical, 36)
  282. .frame(maxWidth: .infinity, minHeight: 180)
  283. .background(AppTheme.panelBackground)
  284. .clipShape(RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall))
  285. .overlay(
  286. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  287. .strokeBorder(
  288. AppTheme.accentPurple.opacity(0.3),
  289. style: StrokeStyle(lineWidth: 1, dash: [6, 4])
  290. )
  291. )
  292. }
  293. }
  294. // MARK: - Markdown
  295. struct PostMarkdownText: View {
  296. let text: String
  297. var fontSize: CGFloat = 12
  298. var color: Color = AppTheme.textSecondary
  299. var lineSpacing: CGFloat = 3
  300. var body: some View {
  301. if let attributed = try? AttributedString(
  302. markdown: text,
  303. options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
  304. ) {
  305. Text(attributed)
  306. .font(.system(size: fontSize))
  307. .foregroundStyle(color)
  308. .lineSpacing(lineSpacing)
  309. .fixedSize(horizontal: false, vertical: true)
  310. .textSelection(.enabled)
  311. } else {
  312. Text(text)
  313. .font(.system(size: fontSize))
  314. .foregroundStyle(color)
  315. .lineSpacing(lineSpacing)
  316. .fixedSize(horizontal: false, vertical: true)
  317. .textSelection(.enabled)
  318. }
  319. }
  320. }
  321. // MARK: - Reddit Preview Card
  322. struct RedditPostPreviewCard: View {
  323. let draft: PostDraft
  324. let formattedSubreddit: String
  325. var body: some View {
  326. VStack(alignment: .leading, spacing: 0) {
  327. previewHeader
  328. previewContent
  329. }
  330. .background(AppTheme.panelBackground)
  331. .clipShape(RoundedRectangle(cornerRadius: AppTheme.cornerRadius))
  332. .overlay(
  333. RoundedRectangle(cornerRadius: AppTheme.cornerRadius)
  334. .stroke(AppTheme.cardBorder, lineWidth: 1)
  335. )
  336. }
  337. private var previewHeader: some View {
  338. HStack(spacing: 8) {
  339. Circle()
  340. .fill(
  341. LinearGradient(
  342. colors: [AppTheme.accentOrange, AppTheme.accentOrange.opacity(0.7)],
  343. startPoint: .topLeading,
  344. endPoint: .bottomTrailing
  345. )
  346. )
  347. .frame(width: 28, height: 28)
  348. .overlay {
  349. Image(systemName: "person.fill")
  350. .font(.system(size: 12))
  351. .foregroundStyle(.white)
  352. }
  353. VStack(alignment: .leading, spacing: 1) {
  354. HStack(spacing: 4) {
  355. Text(formattedSubreddit)
  356. .font(.system(size: 11, weight: .semibold))
  357. .foregroundStyle(AppTheme.textPrimary)
  358. if !draft.flair.isEmpty {
  359. Text(draft.flair)
  360. .font(.system(size: 9, weight: .semibold))
  361. .foregroundStyle(AppTheme.accentBlue)
  362. .padding(.horizontal, 6)
  363. .padding(.vertical, 2)
  364. .background(AppTheme.accentBlue.opacity(0.15))
  365. .clipShape(Capsule())
  366. }
  367. }
  368. Text("u/ReddoraUser · just now")
  369. .font(.system(size: 9))
  370. .foregroundStyle(AppTheme.textTertiary)
  371. }
  372. Spacer()
  373. previewTags
  374. }
  375. .padding(.horizontal, 14)
  376. .padding(.top, 14)
  377. .padding(.bottom, 10)
  378. }
  379. @ViewBuilder
  380. private var previewTags: some View {
  381. HStack(spacing: 4) {
  382. if draft.isNSFW {
  383. previewTag("NSFW", color: Color(hex: 0xEF4444))
  384. }
  385. if draft.isSpoiler {
  386. previewTag("Spoiler", color: AppTheme.textSecondary)
  387. }
  388. if draft.isOC {
  389. previewTag("OC", color: AppTheme.accentGreen)
  390. }
  391. }
  392. }
  393. private func previewTag(_ text: String, color: Color) -> some View {
  394. Text(text)
  395. .font(.system(size: 8, weight: .bold))
  396. .foregroundStyle(color)
  397. .padding(.horizontal, 5)
  398. .padding(.vertical, 2)
  399. .background(color.opacity(0.15))
  400. .clipShape(RoundedRectangle(cornerRadius: 3))
  401. }
  402. @ViewBuilder
  403. private var previewContent: some View {
  404. VStack(alignment: .leading, spacing: 10) {
  405. Text(draft.title.isEmpty ? "Your post title will appear here" : draft.title)
  406. .font(.system(size: 14, weight: .semibold))
  407. .foregroundStyle(draft.title.isEmpty ? AppTheme.textTertiary : AppTheme.textPrimary)
  408. .fixedSize(horizontal: false, vertical: true)
  409. switch draft.postType {
  410. case .text:
  411. textPreview
  412. case .image:
  413. imagePreview
  414. case .link:
  415. linkPreview
  416. case .video:
  417. videoPreview
  418. case .poll:
  419. pollPreview
  420. }
  421. }
  422. .padding(.horizontal, 14)
  423. .padding(.bottom, 14)
  424. }
  425. @ViewBuilder
  426. private var textPreview: some View {
  427. if !draft.body.isEmpty {
  428. PostMarkdownText(text: draft.body)
  429. }
  430. }
  431. @ViewBuilder
  432. private var imagePreview: some View {
  433. if let url = draft.imageFileURL, let nsImage = NSImage(contentsOf: url) {
  434. Image(nsImage: nsImage)
  435. .resizable()
  436. .scaledToFit()
  437. .frame(maxWidth: .infinity, maxHeight: 200)
  438. .frame(height: 200)
  439. .clipped()
  440. .clipShape(RoundedRectangle(cornerRadius: 8))
  441. } else {
  442. imagePlaceholder(icon: "photo", label: "Image preview")
  443. }
  444. if !draft.body.isEmpty {
  445. PostMarkdownText(text: draft.body, fontSize: 11)
  446. }
  447. }
  448. @ViewBuilder
  449. private var linkPreview: some View {
  450. linkCard(
  451. domain: linkDomain,
  452. title: draft.linkURL.isEmpty ? "Link preview" : draft.title,
  453. url: draft.linkURL
  454. )
  455. if !draft.body.isEmpty {
  456. PostMarkdownText(text: draft.body, fontSize: 11)
  457. }
  458. }
  459. @ViewBuilder
  460. private var videoPreview: some View {
  461. if draft.videoURL.isEmpty {
  462. imagePlaceholder(icon: "play.rectangle", label: "Video preview")
  463. } else {
  464. linkCard(
  465. domain: videoDomain,
  466. title: draft.title.isEmpty ? "Video link" : draft.title,
  467. url: draft.videoURL
  468. )
  469. }
  470. if !draft.body.isEmpty {
  471. PostMarkdownText(text: draft.body, fontSize: 11)
  472. }
  473. }
  474. @ViewBuilder
  475. private var pollPreview: some View {
  476. if !draft.body.isEmpty {
  477. PostMarkdownText(text: draft.body, fontSize: 11)
  478. }
  479. VStack(spacing: 6) {
  480. ForEach(draft.pollOptions) { option in
  481. HStack {
  482. Text(option.text.isEmpty ? "Poll option" : option.text)
  483. .font(.system(size: 11))
  484. .foregroundStyle(option.text.isEmpty ? AppTheme.textTertiary : AppTheme.textPrimary)
  485. Spacer()
  486. Circle()
  487. .stroke(AppTheme.cardBorder, lineWidth: 1.5)
  488. .frame(width: 14, height: 14)
  489. }
  490. .padding(.horizontal, 10)
  491. .padding(.vertical, 8)
  492. .background(AppTheme.cardBackground)
  493. .clipShape(RoundedRectangle(cornerRadius: 6))
  494. }
  495. }
  496. Text("Poll ends in \(draft.pollDuration.label)")
  497. .font(.system(size: 9))
  498. .foregroundStyle(AppTheme.textTertiary)
  499. }
  500. private func imagePlaceholder(icon: String, label: String) -> some View {
  501. RoundedRectangle(cornerRadius: 8)
  502. .fill(AppTheme.cardBackground)
  503. .frame(height: 140)
  504. .overlay {
  505. VStack(spacing: 6) {
  506. Image(systemName: icon)
  507. .font(.system(size: 24))
  508. .foregroundStyle(AppTheme.textTertiary)
  509. Text(label)
  510. .font(.system(size: 10))
  511. .foregroundStyle(AppTheme.textTertiary)
  512. }
  513. }
  514. }
  515. private func linkCard(domain: String, title: String, url: String) -> some View {
  516. HStack(spacing: 10) {
  517. RoundedRectangle(cornerRadius: 6)
  518. .fill(AppTheme.cardBackground)
  519. .frame(width: 60, height: 60)
  520. .overlay {
  521. Image(systemName: "link")
  522. .font(.system(size: 18))
  523. .foregroundStyle(AppTheme.textTertiary)
  524. }
  525. VStack(alignment: .leading, spacing: 3) {
  526. Text(domain.isEmpty ? "example.com" : domain)
  527. .font(.system(size: 9))
  528. .foregroundStyle(AppTheme.textTertiary)
  529. Text(title)
  530. .font(.system(size: 11, weight: .medium))
  531. .foregroundStyle(AppTheme.textPrimary)
  532. .lineLimit(2)
  533. }
  534. Spacer(minLength: 0)
  535. }
  536. .padding(10)
  537. .background(AppTheme.cardBackground)
  538. .clipShape(RoundedRectangle(cornerRadius: 8))
  539. .overlay(
  540. RoundedRectangle(cornerRadius: 8)
  541. .stroke(AppTheme.cardBorder, lineWidth: 1)
  542. )
  543. }
  544. private var linkDomain: String {
  545. guard let url = URL(string: draft.linkURL), let host = url.host else { return "" }
  546. return host
  547. }
  548. private var videoDomain: String {
  549. guard let url = URL(string: draft.videoURL), let host = url.host else { return "" }
  550. return host
  551. }
  552. }