PostGeneratorView.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  1. import AppKit
  2. import SwiftUI
  3. import UniformTypeIdentifiers
  4. struct PostGeneratorView: View {
  5. @Bindable var viewModel: PostGeneratorViewModel
  6. var body: some View {
  7. VStack(spacing: 0) {
  8. header
  9. tabBar
  10. ScrollView {
  11. switch viewModel.selectedTab {
  12. case .compose:
  13. composeContent
  14. case .preview:
  15. previewContent
  16. }
  17. }
  18. }
  19. .background(AppTheme.background)
  20. .fileImporter(
  21. isPresented: $viewModel.showImageImporter,
  22. allowedContentTypes: [.image],
  23. allowsMultipleSelection: false
  24. ) { result in
  25. if case .success(let urls) = result, let url = urls.first {
  26. viewModel.setImage(from: url)
  27. }
  28. }
  29. }
  30. // MARK: - Header
  31. private var header: some View {
  32. HStack(spacing: 14) {
  33. ModernSidebarIconView(kind: .postGenerator, size: 40)
  34. VStack(alignment: .leading, spacing: 2) {
  35. Text("Post Generator")
  36. .font(.system(size: 18, weight: .semibold))
  37. .foregroundStyle(AppTheme.textPrimary)
  38. Text("Create Reddit-ready posts with AI")
  39. .font(.system(size: 11))
  40. .foregroundStyle(AppTheme.textSecondary)
  41. }
  42. Spacer()
  43. headerActions
  44. }
  45. .padding(.horizontal, 24)
  46. .padding(.vertical, 16)
  47. .overlay(alignment: .bottom) {
  48. Rectangle()
  49. .fill(AppTheme.border)
  50. .frame(height: 1)
  51. }
  52. }
  53. private var headerActions: some View {
  54. HStack(spacing: 8) {
  55. Button {
  56. viewModel.resetDraft()
  57. } label: {
  58. Label("Reset", systemImage: "arrow.counterclockwise")
  59. .font(.system(size: 11, weight: .medium))
  60. }
  61. .buttonStyle(PostSecondaryButtonStyle())
  62. Button {
  63. viewModel.copyToClipboard()
  64. } label: {
  65. Label("Copy", systemImage: "doc.on.doc")
  66. .font(.system(size: 11, weight: .medium))
  67. }
  68. .buttonStyle(PostSecondaryButtonStyle())
  69. .disabled(viewModel.draft.title.isEmpty)
  70. Button {
  71. Task { await viewModel.generatePost() }
  72. } label: {
  73. HStack(spacing: 6) {
  74. if viewModel.isGenerating {
  75. ProgressView()
  76. .controlSize(.small)
  77. .tint(.white)
  78. } else {
  79. Image(systemName: "sparkles")
  80. .font(.system(size: 11, weight: .semibold))
  81. }
  82. Text(viewModel.isGenerating ? "Generating…" : "Generate")
  83. .font(.system(size: 11, weight: .semibold))
  84. }
  85. .foregroundStyle(.white)
  86. .padding(.horizontal, 14)
  87. .padding(.vertical, 8)
  88. .background(viewModel.canGenerate ? AppTheme.accentPurple : AppTheme.accentPurple.opacity(0.4))
  89. .clipShape(RoundedRectangle(cornerRadius: 8))
  90. }
  91. .buttonStyle(.plain)
  92. .disabled(!viewModel.canGenerate)
  93. }
  94. }
  95. // MARK: - Tab Bar
  96. private var tabBar: some View {
  97. HStack(spacing: 4) {
  98. ForEach(PostGeneratorTab.allCases) { tab in
  99. Button {
  100. viewModel.selectedTab = tab
  101. } label: {
  102. Text(tab.title)
  103. .font(.system(size: 11, weight: .semibold))
  104. .foregroundStyle(viewModel.selectedTab == tab ? AppTheme.textPrimary : AppTheme.textSecondary)
  105. .padding(.horizontal, 14)
  106. .padding(.vertical, 8)
  107. .background(
  108. viewModel.selectedTab == tab
  109. ? AppTheme.cardBackground
  110. : Color.clear
  111. )
  112. .clipShape(RoundedRectangle(cornerRadius: 8))
  113. }
  114. .buttonStyle(.plain)
  115. }
  116. Spacer()
  117. }
  118. .padding(.horizontal, 24)
  119. .padding(.top, 12)
  120. .padding(.bottom, 4)
  121. }
  122. // MARK: - Compose
  123. private var composeContent: some View {
  124. HStack(alignment: .top, spacing: 16) {
  125. configurationPanel
  126. editorPanel
  127. }
  128. .padding(24)
  129. .padding(.top, 8)
  130. }
  131. private var configurationPanel: some View {
  132. VStack(spacing: 12) {
  133. PostFormCard(title: "Post Type", subtitle: "Choose how you want to post") {
  134. PostTypePicker(selectedType: Binding(
  135. get: { viewModel.draft.postType },
  136. set: { viewModel.selectPostType($0) }
  137. ))
  138. }
  139. PostFormCard(title: "Target Subreddit", subtitle: "Where will this be posted?") {
  140. PostFormField(
  141. label: "Subreddit",
  142. placeholder: "technology",
  143. text: $viewModel.draft.subreddit,
  144. prefix: "r/"
  145. )
  146. }
  147. PostFormCard(title: "AI Settings", subtitle: "Guide the tone and topic") {
  148. VStack(alignment: .leading, spacing: 12) {
  149. PostFormField(
  150. label: "Topic / Keywords",
  151. placeholder: "e.g. macOS productivity tips",
  152. text: $viewModel.draft.topic
  153. )
  154. VStack(alignment: .leading, spacing: 6) {
  155. Text("Tone")
  156. .font(.system(size: 10, weight: .medium))
  157. .foregroundStyle(AppTheme.textTertiary)
  158. TonePicker(selectedTone: $viewModel.draft.tone)
  159. }
  160. }
  161. }
  162. PostFormCard(title: "Post Tags", subtitle: "Reddit content labels") {
  163. HStack(spacing: 6) {
  164. PostTagToggle(
  165. title: "NSFW",
  166. systemImage: "exclamationmark.triangle.fill",
  167. activeColor: Color(hex: 0xEF4444),
  168. isOn: $viewModel.draft.isNSFW
  169. )
  170. PostTagToggle(
  171. title: "Spoiler",
  172. systemImage: "eye.slash.fill",
  173. activeColor: AppTheme.textSecondary,
  174. isOn: $viewModel.draft.isSpoiler
  175. )
  176. PostTagToggle(
  177. title: "OC",
  178. systemImage: "star.fill",
  179. activeColor: AppTheme.accentGreen,
  180. isOn: $viewModel.draft.isOC
  181. )
  182. }
  183. }
  184. PostFormCard(title: "Flair", subtitle: "Optional subreddit flair") {
  185. PostFormField(
  186. label: "Flair",
  187. placeholder: "Discussion, Question, Meme…",
  188. text: $viewModel.draft.flair
  189. )
  190. }
  191. if let error = viewModel.errorMessage {
  192. PostGeneratorMessageBanner(message: error, isError: true)
  193. } else if let success = viewModel.successMessage {
  194. PostGeneratorMessageBanner(message: success, isError: false)
  195. }
  196. }
  197. .frame(width: 300)
  198. }
  199. private var editorPanel: some View {
  200. VStack(spacing: 12) {
  201. PostFormCard(title: "Title", subtitle: "Required for all post types") {
  202. PostFormField(
  203. label: "Post Title",
  204. placeholder: "An engaging title for your post",
  205. text: $viewModel.draft.title
  206. )
  207. }
  208. postTypeEditor
  209. characterCountFooter
  210. }
  211. .frame(maxWidth: .infinity)
  212. }
  213. @ViewBuilder
  214. private var postTypeEditor: some View {
  215. switch viewModel.draft.postType {
  216. case .text:
  217. textPostEditor
  218. case .image:
  219. imagePostEditor
  220. case .link:
  221. linkPostEditor
  222. case .video:
  223. videoPostEditor
  224. case .poll:
  225. pollPostEditor
  226. }
  227. }
  228. private var textPostEditor: some View {
  229. PostFormCard(title: "Body", subtitle: "Markdown supported — **bold**, *italic*, links") {
  230. PostFormTextEditor(
  231. label: "Post Body",
  232. placeholder: "Write your post content here…",
  233. text: $viewModel.draft.body,
  234. minHeight: 220
  235. )
  236. }
  237. }
  238. private var imagePostEditor: some View {
  239. PostFormCard(title: "Image", subtitle: "Upload an image for your post") {
  240. VStack(alignment: .leading, spacing: 12) {
  241. imageUploadArea
  242. PostFormTextEditor(
  243. label: "Caption (optional)",
  244. placeholder: "Add context for your image…",
  245. text: $viewModel.draft.body,
  246. minHeight: 80
  247. )
  248. }
  249. }
  250. }
  251. private var linkPostEditor: some View {
  252. PostFormCard(title: "Link", subtitle: "Share a URL with the community") {
  253. VStack(alignment: .leading, spacing: 12) {
  254. PostFormField(
  255. label: "URL",
  256. placeholder: "https://example.com/article",
  257. text: $viewModel.draft.linkURL
  258. )
  259. PostFormTextEditor(
  260. label: "Description (optional)",
  261. placeholder: "Why are you sharing this link?",
  262. text: $viewModel.draft.body,
  263. minHeight: 100
  264. )
  265. }
  266. }
  267. }
  268. private var videoPostEditor: some View {
  269. PostFormCard(title: "Video", subtitle: "Link to YouTube, Vimeo, or direct video URL") {
  270. VStack(alignment: .leading, spacing: 12) {
  271. PostFormField(
  272. label: "Video URL",
  273. placeholder: "https://youtube.com/watch?v=…",
  274. text: $viewModel.draft.videoURL
  275. )
  276. PostFormTextEditor(
  277. label: "Description (optional)",
  278. placeholder: "Add context for your video…",
  279. text: $viewModel.draft.body,
  280. minHeight: 100
  281. )
  282. }
  283. }
  284. }
  285. private var pollPostEditor: some View {
  286. PostFormCard(title: "Poll", subtitle: "2–6 options, community votes") {
  287. VStack(alignment: .leading, spacing: 12) {
  288. ForEach(Array(viewModel.draft.pollOptions.enumerated()), id: \.element.id) { index, option in
  289. HStack(spacing: 8) {
  290. PostFormField(
  291. label: "Option \(index + 1)",
  292. placeholder: "Enter poll option",
  293. text: Binding(
  294. get: { option.text },
  295. set: { newValue in
  296. if let idx = viewModel.draft.pollOptions.firstIndex(where: { $0.id == option.id }) {
  297. viewModel.draft.pollOptions[idx].text = newValue
  298. }
  299. }
  300. )
  301. )
  302. if viewModel.canRemovePollOption {
  303. Button {
  304. viewModel.removePollOption(option)
  305. } label: {
  306. Image(systemName: "minus.circle.fill")
  307. .font(.system(size: 14))
  308. .foregroundStyle(Color(hex: 0xEF4444).opacity(0.8))
  309. }
  310. .buttonStyle(.plain)
  311. .padding(.top, 18)
  312. }
  313. }
  314. }
  315. if viewModel.canAddPollOption {
  316. Button {
  317. viewModel.addPollOption()
  318. } label: {
  319. Label("Add Option", systemImage: "plus.circle.fill")
  320. .font(.system(size: 11, weight: .medium))
  321. .foregroundStyle(AppTheme.accentPurpleLight)
  322. }
  323. .buttonStyle(.plain)
  324. }
  325. VStack(alignment: .leading, spacing: 6) {
  326. Text("Poll Duration")
  327. .font(.system(size: 10, weight: .medium))
  328. .foregroundStyle(AppTheme.textTertiary)
  329. HStack(spacing: 6) {
  330. ForEach(PollDuration.allCases) { duration in
  331. Button {
  332. viewModel.draft.pollDuration = duration
  333. } label: {
  334. Text(duration.label)
  335. .font(.system(size: 10, weight: .semibold))
  336. .foregroundStyle(
  337. viewModel.draft.pollDuration == duration ? .white : AppTheme.textSecondary
  338. )
  339. .padding(.horizontal, 12)
  340. .padding(.vertical, 6)
  341. .background(
  342. Capsule()
  343. .fill(
  344. viewModel.draft.pollDuration == duration
  345. ? AppTheme.accentPurple
  346. : AppTheme.panelBackground
  347. )
  348. )
  349. .overlay(
  350. Capsule()
  351. .stroke(AppTheme.cardBorder, lineWidth: 1)
  352. )
  353. }
  354. .buttonStyle(.plain)
  355. }
  356. }
  357. }
  358. PostFormTextEditor(
  359. label: "Context (optional)",
  360. placeholder: "Explain why you're running this poll…",
  361. text: $viewModel.draft.body,
  362. minHeight: 80
  363. )
  364. }
  365. }
  366. }
  367. @ViewBuilder
  368. private var imageUploadArea: some View {
  369. if let url = viewModel.draft.imageFileURL, let nsImage = NSImage(contentsOf: url) {
  370. ZStack(alignment: .topTrailing) {
  371. Image(nsImage: nsImage)
  372. .resizable()
  373. .aspectRatio(contentMode: .fit)
  374. .frame(maxHeight: 180)
  375. .frame(maxWidth: .infinity)
  376. .clipShape(RoundedRectangle(cornerRadius: 8))
  377. Button {
  378. viewModel.removeImage()
  379. } label: {
  380. Image(systemName: "xmark.circle.fill")
  381. .font(.system(size: 18))
  382. .foregroundStyle(.white)
  383. .shadow(radius: 2)
  384. }
  385. .buttonStyle(.plain)
  386. .padding(8)
  387. }
  388. } else {
  389. Button {
  390. viewModel.showImageImporter = true
  391. } label: {
  392. VStack(spacing: 8) {
  393. Image(systemName: "photo.badge.plus")
  394. .font(.system(size: 24))
  395. .foregroundStyle(AppTheme.accentPurpleLight)
  396. Text("Click to upload image")
  397. .font(.system(size: 11, weight: .medium))
  398. .foregroundStyle(AppTheme.textSecondary)
  399. Text("PNG, JPG, GIF, WebP")
  400. .font(.system(size: 9))
  401. .foregroundStyle(AppTheme.textTertiary)
  402. }
  403. .frame(maxWidth: .infinity)
  404. .frame(height: 140)
  405. .background(AppTheme.panelBackground)
  406. .clipShape(RoundedRectangle(cornerRadius: 8))
  407. .overlay(
  408. RoundedRectangle(cornerRadius: 8)
  409. .strokeBorder(
  410. AppTheme.accentPurple.opacity(0.3),
  411. style: StrokeStyle(lineWidth: 1, dash: [6, 4])
  412. )
  413. )
  414. }
  415. .buttonStyle(.plain)
  416. }
  417. }
  418. private var characterCountFooter: some View {
  419. HStack {
  420. Text(titleCharacterCount)
  421. .font(.system(size: 10))
  422. .foregroundStyle(
  423. viewModel.draft.title.count > 300 ? Color(hex: 0xF87171) : AppTheme.textTertiary
  424. )
  425. Spacer()
  426. Text("Reddit limit: 300 characters for title")
  427. .font(.system(size: 10))
  428. .foregroundStyle(AppTheme.textTertiary)
  429. }
  430. }
  431. private var titleCharacterCount: String {
  432. "\(viewModel.draft.title.count) / 300"
  433. }
  434. // MARK: - Preview
  435. private var previewContent: some View {
  436. VStack(spacing: 16) {
  437. Text("Live Preview")
  438. .font(.system(size: 12, weight: .semibold))
  439. .foregroundStyle(AppTheme.textSecondary)
  440. .frame(maxWidth: .infinity, alignment: .leading)
  441. RedditPostPreviewCard(
  442. draft: viewModel.draft,
  443. formattedSubreddit: viewModel.formattedSubreddit
  444. )
  445. previewMetadata
  446. }
  447. .padding(24)
  448. .padding(.top, 8)
  449. .frame(maxWidth: 560)
  450. .frame(maxWidth: .infinity)
  451. }
  452. private var previewMetadata: some View {
  453. VStack(alignment: .leading, spacing: 8) {
  454. metadataRow(label: "Type", value: viewModel.draft.postType.title)
  455. metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
  456. metadataRow(label: "Tone", value: viewModel.draft.tone.title)
  457. if viewModel.draft.isNSFW || viewModel.draft.isSpoiler || viewModel.draft.isOC {
  458. metadataRow(
  459. label: "Tags",
  460. value: [
  461. viewModel.draft.isNSFW ? "NSFW" : nil,
  462. viewModel.draft.isSpoiler ? "Spoiler" : nil,
  463. viewModel.draft.isOC ? "OC" : nil,
  464. ]
  465. .compactMap { $0 }
  466. .joined(separator: ", ")
  467. )
  468. }
  469. }
  470. .padding(14)
  471. .frame(maxWidth: .infinity, alignment: .leading)
  472. .background(
  473. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  474. .fill(AppTheme.cardBackground)
  475. .overlay(
  476. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  477. .stroke(AppTheme.cardBorder, lineWidth: 1)
  478. )
  479. )
  480. }
  481. private func metadataRow(label: String, value: String) -> some View {
  482. HStack {
  483. Text(label)
  484. .font(.system(size: 10, weight: .medium))
  485. .foregroundStyle(AppTheme.textTertiary)
  486. .frame(width: 70, alignment: .leading)
  487. Text(value)
  488. .font(.system(size: 10))
  489. .foregroundStyle(AppTheme.textSecondary)
  490. }
  491. }
  492. }
  493. private struct PostSecondaryButtonStyle: ButtonStyle {
  494. func makeBody(configuration: Configuration) -> some View {
  495. configuration.label
  496. .foregroundStyle(AppTheme.textSecondary)
  497. .padding(.horizontal, 12)
  498. .padding(.vertical, 8)
  499. .background(AppTheme.cardBackground.opacity(configuration.isPressed ? 0.6 : 1))
  500. .clipShape(RoundedRectangle(cornerRadius: 8))
  501. .overlay(
  502. RoundedRectangle(cornerRadius: 8)
  503. .stroke(AppTheme.cardBorder, lineWidth: 1)
  504. )
  505. }
  506. }
  507. #Preview {
  508. PostGeneratorView(viewModel: PostGeneratorViewModel())
  509. .frame(width: 880, height: 720)
  510. }