PostGeneratorView.swift 25 KB

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