TitleOptimizerComponents.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. import SwiftUI
  2. // MARK: - Title Goal Picker
  3. struct TitleGoalPicker: View {
  4. @Binding var selectedGoal: TitleGoal
  5. var body: some View {
  6. LazyVGrid(
  7. columns: [GridItem(.flexible()), GridItem(.flexible())],
  8. spacing: 8
  9. ) {
  10. ForEach(TitleGoal.allCases) { goal in
  11. Button {
  12. selectedGoal = goal
  13. } label: {
  14. VStack(alignment: .leading, spacing: 2) {
  15. Text(goal.title)
  16. .font(.system(size: 11, weight: .semibold))
  17. .foregroundStyle(AppTheme.textPrimary)
  18. Text(goal.subtitle)
  19. .font(.system(size: 9))
  20. .foregroundStyle(AppTheme.textTertiary)
  21. .lineLimit(1)
  22. }
  23. .frame(maxWidth: .infinity, alignment: .leading)
  24. .padding(.horizontal, 10)
  25. .padding(.vertical, 8)
  26. .background(
  27. RoundedRectangle(cornerRadius: 8)
  28. .fill(
  29. selectedGoal == goal
  30. ? AppTheme.accentBlue.opacity(0.15)
  31. : AppTheme.panelBackground
  32. )
  33. )
  34. .overlay(
  35. RoundedRectangle(cornerRadius: 8)
  36. .stroke(
  37. selectedGoal == goal
  38. ? AppTheme.accentBlue.opacity(0.5)
  39. : AppTheme.cardBorder,
  40. lineWidth: 1
  41. )
  42. )
  43. }
  44. .buttonStyle(AppPlainButtonStyle())
  45. .hoverOverlay(cornerRadius: 8)
  46. }
  47. }
  48. }
  49. }
  50. // MARK: - Variant Count Picker
  51. struct TitleVariantCountPicker: View {
  52. @Binding var selectedCount: TitleVariantCount
  53. var body: some View {
  54. HStack(spacing: 8) {
  55. ForEach(TitleVariantCount.allCases) { count in
  56. Button {
  57. selectedCount = count
  58. } label: {
  59. Text(count.label)
  60. .font(.system(size: 10, weight: .semibold))
  61. .foregroundStyle(selectedCount == count ? .white : AppTheme.textSecondary)
  62. .frame(maxWidth: .infinity)
  63. .padding(.vertical, 7)
  64. .background(
  65. RoundedRectangle(cornerRadius: 7)
  66. .fill(
  67. selectedCount == count
  68. ? AppTheme.accentBlue
  69. : AppTheme.panelBackground
  70. )
  71. )
  72. .overlay(
  73. RoundedRectangle(cornerRadius: 7)
  74. .stroke(AppTheme.cardBorder, lineWidth: 1)
  75. )
  76. }
  77. .buttonStyle(AppPlainButtonStyle())
  78. .hoverOverlay(cornerRadius: 8)
  79. }
  80. }
  81. }
  82. }
  83. // MARK: - Title Analysis Card
  84. struct TitleAnalysisCard: View {
  85. let analysis: TitleAnalysis
  86. var body: some View {
  87. VStack(alignment: .leading, spacing: 14) {
  88. HStack {
  89. VStack(alignment: .leading, spacing: 2) {
  90. Text("Title Analysis")
  91. .font(.system(size: 12, weight: .semibold))
  92. .foregroundStyle(AppTheme.textPrimary)
  93. Text("How your current title performs")
  94. .font(.system(size: 10))
  95. .foregroundStyle(AppTheme.textSecondary)
  96. }
  97. Spacer()
  98. TitleScoreBadge(score: analysis.overallScore, size: .large)
  99. }
  100. HStack(spacing: 12) {
  101. scoreMetric(label: "Length", score: analysis.lengthScore)
  102. scoreMetric(label: "Engagement", score: analysis.engagementScore)
  103. scoreMetric(label: "Clarity", score: analysis.clarityScore)
  104. }
  105. if !analysis.suggestions.isEmpty {
  106. VStack(alignment: .leading, spacing: 6) {
  107. Text("Suggestions")
  108. .font(.system(size: 10, weight: .medium))
  109. .foregroundStyle(AppTheme.textTertiary)
  110. ForEach(analysis.suggestions, id: \.self) { suggestion in
  111. HStack(alignment: .top, spacing: 6) {
  112. Image(systemName: "lightbulb.fill")
  113. .font(.system(size: 9))
  114. .foregroundStyle(AppTheme.accentYellow)
  115. .padding(.top, 2)
  116. Text(suggestion)
  117. .font(.system(size: 10))
  118. .foregroundStyle(AppTheme.textSecondary)
  119. .fixedSize(horizontal: false, vertical: true)
  120. }
  121. }
  122. }
  123. }
  124. }
  125. .padding(14)
  126. .frame(maxWidth: .infinity, alignment: .leading)
  127. .background(
  128. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  129. .fill(AppTheme.cardBackground)
  130. .overlay(
  131. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  132. .stroke(AppTheme.cardBorder, lineWidth: 1)
  133. )
  134. )
  135. }
  136. private func scoreMetric(label: String, score: Int) -> some View {
  137. VStack(spacing: 4) {
  138. TitleScoreBadge(score: score, size: .small)
  139. Text(label)
  140. .font(.system(size: 9, weight: .medium))
  141. .foregroundStyle(AppTheme.textTertiary)
  142. }
  143. .frame(maxWidth: .infinity)
  144. }
  145. }
  146. // MARK: - Score Badge
  147. struct TitleScoreBadge: View {
  148. enum Size {
  149. case small
  150. case large
  151. var fontSize: CGFloat {
  152. switch self {
  153. case .small: 11
  154. case .large: 18
  155. }
  156. }
  157. var padding: CGFloat {
  158. switch self {
  159. case .small: 6
  160. case .large: 10
  161. }
  162. }
  163. }
  164. let score: Int
  165. var size: Size = .small
  166. var body: some View {
  167. Text("\(score)")
  168. .font(.system(size: size.fontSize, weight: .bold, design: .rounded))
  169. .foregroundStyle(scoreColor)
  170. .padding(size.padding)
  171. .background(
  172. Circle()
  173. .fill(scoreColor.opacity(0.15))
  174. )
  175. .overlay(
  176. Circle()
  177. .stroke(scoreColor.opacity(0.3), lineWidth: 1)
  178. )
  179. }
  180. private var scoreColor: Color {
  181. switch score {
  182. case 80...100: AppTheme.accentGreen
  183. case 60..<80: AppTheme.accentYellow
  184. case 40..<60: AppTheme.accentOrange
  185. default: Color(hex: 0xEF4444)
  186. }
  187. }
  188. }
  189. // MARK: - Title Variant Row
  190. struct TitleVariantRow: View {
  191. let variant: TitleVariant
  192. let isSelected: Bool
  193. let onSelect: () -> Void
  194. let onApply: () -> Void
  195. var body: some View {
  196. HStack(alignment: .top, spacing: 12) {
  197. Button(action: onSelect) {
  198. Circle()
  199. .stroke(isSelected ? AppTheme.accentBlue : AppTheme.cardBorder, lineWidth: 2)
  200. .frame(width: 16, height: 16)
  201. .overlay {
  202. if isSelected {
  203. Circle()
  204. .fill(AppTheme.accentBlue)
  205. .frame(width: 8, height: 8)
  206. }
  207. }
  208. }
  209. .buttonStyle(AppPlainButtonStyle())
  210. .hoverOverlay(cornerRadius: 8)
  211. .padding(.top, 2)
  212. VStack(alignment: .leading, spacing: 6) {
  213. HStack(alignment: .top) {
  214. Text(variant.title)
  215. .font(.system(size: 12, weight: .medium))
  216. .foregroundStyle(AppTheme.textPrimary)
  217. .fixedSize(horizontal: false, vertical: true)
  218. Spacer(minLength: 8)
  219. TitleScoreBadge(score: variant.score)
  220. }
  221. Text(variant.reasoning)
  222. .font(.system(size: 10))
  223. .foregroundStyle(AppTheme.textTertiary)
  224. HStack(spacing: 8) {
  225. Text("\(variant.title.count) chars")
  226. .font(.system(size: 9))
  227. .foregroundStyle(
  228. variant.title.count > 300 ? Color(hex: 0xF87171) : AppTheme.textTertiary
  229. )
  230. Spacer()
  231. Button(action: onApply) {
  232. Text("Use Title")
  233. .font(.system(size: 10, weight: .semibold))
  234. .foregroundStyle(AppTheme.accentBlue)
  235. }
  236. .buttonStyle(AppPlainButtonStyle())
  237. .hoverOverlay(cornerRadius: 8)
  238. }
  239. }
  240. }
  241. .padding(12)
  242. .background(
  243. RoundedRectangle(cornerRadius: 8)
  244. .fill(isSelected ? AppTheme.accentBlue.opacity(0.08) : AppTheme.panelBackground)
  245. )
  246. .overlay(
  247. RoundedRectangle(cornerRadius: 8)
  248. .stroke(
  249. isSelected ? AppTheme.accentBlue.opacity(0.4) : AppTheme.cardBorder,
  250. lineWidth: 1
  251. )
  252. )
  253. .hoverCard(cornerRadius: 8, isSelected: isSelected)
  254. }
  255. }
  256. // MARK: - Compare Card
  257. struct TitleCompareCard: View {
  258. let title: String
  259. let score: Int?
  260. let label: String
  261. let isOriginal: Bool
  262. var isSelected: Bool = false
  263. var body: some View {
  264. VStack(alignment: .leading, spacing: 10) {
  265. HStack {
  266. Text(label)
  267. .font(.system(size: 10, weight: .semibold))
  268. .foregroundStyle(isOriginal ? AppTheme.textSecondary : AppTheme.accentBlue)
  269. .padding(.horizontal, 8)
  270. .padding(.vertical, 3)
  271. .background(
  272. Capsule()
  273. .fill(
  274. isOriginal
  275. ? AppTheme.panelBackground
  276. : AppTheme.accentBlue.opacity(0.15)
  277. )
  278. )
  279. Spacer()
  280. if let score {
  281. TitleScoreBadge(score: score)
  282. }
  283. }
  284. Text(title.isEmpty ? "No title" : title)
  285. .font(.system(size: 13, weight: .semibold))
  286. .foregroundStyle(title.isEmpty ? AppTheme.textTertiary : AppTheme.textPrimary)
  287. .fixedSize(horizontal: false, vertical: true)
  288. HStack {
  289. Text("\(title.count) / 300 characters")
  290. .font(.system(size: 9))
  291. .foregroundStyle(
  292. title.count > 300 ? Color(hex: 0xF87171) : AppTheme.textTertiary
  293. )
  294. Spacer()
  295. if isSelected {
  296. Label("Active", systemImage: "checkmark.circle.fill")
  297. .font(.system(size: 9, weight: .semibold))
  298. .foregroundStyle(AppTheme.accentGreen)
  299. }
  300. }
  301. }
  302. .padding(14)
  303. .frame(maxWidth: .infinity, alignment: .leading)
  304. .background(
  305. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  306. .fill(AppTheme.cardBackground)
  307. .overlay(
  308. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  309. .stroke(
  310. isSelected ? AppTheme.accentBlue.opacity(0.5) : AppTheme.cardBorder,
  311. lineWidth: isSelected ? 1.5 : 1
  312. )
  313. )
  314. )
  315. .hoverCard(cornerRadius: AppTheme.cornerRadiusSmall, isSelected: isSelected)
  316. .hoverCursor()
  317. }
  318. }
  319. // MARK: - Feed Preview Row
  320. struct TitleFeedPreviewRow: View {
  321. let title: String
  322. let subreddit: String
  323. var isHighlighted: Bool = false
  324. var body: some View {
  325. HStack(alignment: .top, spacing: 10) {
  326. RoundedRectangle(cornerRadius: 4)
  327. .fill(AppTheme.cardBackground)
  328. .frame(width: 72, height: 52)
  329. .overlay {
  330. Image(systemName: "photo")
  331. .font(.system(size: 16))
  332. .foregroundStyle(AppTheme.textTertiary)
  333. }
  334. VStack(alignment: .leading, spacing: 4) {
  335. Text(subreddit)
  336. .font(.system(size: 9, weight: .semibold))
  337. .foregroundStyle(AppTheme.textTertiary)
  338. Text(title.isEmpty ? "Title preview" : title)
  339. .font(.system(size: 11, weight: .semibold))
  340. .foregroundStyle(title.isEmpty ? AppTheme.textTertiary : AppTheme.textPrimary)
  341. .lineLimit(2)
  342. .fixedSize(horizontal: false, vertical: true)
  343. }
  344. Spacer(minLength: 0)
  345. }
  346. .padding(10)
  347. .background(
  348. RoundedRectangle(cornerRadius: 8)
  349. .fill(isHighlighted ? AppTheme.accentBlue.opacity(0.08) : AppTheme.panelBackground)
  350. )
  351. .overlay(
  352. RoundedRectangle(cornerRadius: 8)
  353. .stroke(
  354. isHighlighted ? AppTheme.accentBlue.opacity(0.4) : AppTheme.cardBorder,
  355. lineWidth: 1
  356. )
  357. )
  358. }
  359. }
  360. // MARK: - Empty State
  361. struct TitleOptimizerEmptyState: View {
  362. let icon: String
  363. let title: String
  364. let subtitle: String
  365. var body: some View {
  366. VStack(spacing: 10) {
  367. Image(systemName: icon)
  368. .font(.system(size: 28))
  369. .foregroundStyle(AppTheme.accentBlue.opacity(0.6))
  370. Text(title)
  371. .font(.system(size: 12, weight: .semibold))
  372. .foregroundStyle(AppTheme.textSecondary)
  373. Text(subtitle)
  374. .font(.system(size: 10))
  375. .foregroundStyle(AppTheme.textTertiary)
  376. .multilineTextAlignment(.center)
  377. }
  378. .frame(maxWidth: .infinity)
  379. .padding(.vertical, 32)
  380. .background(
  381. RoundedRectangle(cornerRadius: 8)
  382. .fill(AppTheme.panelBackground)
  383. .overlay(
  384. RoundedRectangle(cornerRadius: 8)
  385. .strokeBorder(
  386. AppTheme.accentBlue.opacity(0.2),
  387. style: StrokeStyle(lineWidth: 1, dash: [6, 4])
  388. )
  389. )
  390. )
  391. }
  392. }