PostGeneratorComponents.swift 17 KB

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