PostGeneratorComponents.swift 19 KB

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