PostGeneratorComponents.swift 18 KB

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