TitleOptimizerComponents.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  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 onApply: () -> Void
  194. var body: some View {
  195. HStack(alignment: .top, spacing: 12) {
  196. Button(action: onApply) {
  197. Circle()
  198. .stroke(isSelected ? AppTheme.accentBlue : AppTheme.cardBorder, lineWidth: 2)
  199. .frame(width: 16, height: 16)
  200. .overlay {
  201. if isSelected {
  202. Circle()
  203. .fill(AppTheme.accentBlue)
  204. .frame(width: 8, height: 8)
  205. }
  206. }
  207. }
  208. .buttonStyle(AppPlainButtonStyle())
  209. .hoverOverlay(cornerRadius: 8)
  210. .padding(.top, 2)
  211. VStack(alignment: .leading, spacing: 6) {
  212. HStack(alignment: .top) {
  213. Text(variant.title)
  214. .font(.system(size: 12, weight: .medium))
  215. .foregroundStyle(AppTheme.textPrimary)
  216. .fixedSize(horizontal: false, vertical: true)
  217. Spacer(minLength: 8)
  218. TitleScoreBadge(score: variant.score)
  219. }
  220. Text(variant.reasoning)
  221. .font(.system(size: 10))
  222. .foregroundStyle(AppTheme.textTertiary)
  223. HStack(spacing: 8) {
  224. Text("\(variant.title.count) chars")
  225. .font(.system(size: 9))
  226. .foregroundStyle(
  227. variant.title.count > 300 ? Color(hex: 0xF87171) : AppTheme.textTertiary
  228. )
  229. Spacer()
  230. Button(action: onApply) {
  231. Text("Use Title")
  232. .font(.system(size: 10, weight: .semibold))
  233. .foregroundStyle(AppTheme.accentBlue)
  234. }
  235. .buttonStyle(AppPlainButtonStyle())
  236. .hoverOverlay(cornerRadius: 8)
  237. }
  238. }
  239. }
  240. .padding(12)
  241. .background(
  242. RoundedRectangle(cornerRadius: 8)
  243. .fill(isSelected ? AppTheme.accentBlue.opacity(0.08) : AppTheme.panelBackground)
  244. )
  245. .overlay(
  246. RoundedRectangle(cornerRadius: 8)
  247. .stroke(
  248. isSelected ? AppTheme.accentBlue.opacity(0.4) : AppTheme.cardBorder,
  249. lineWidth: 1
  250. )
  251. )
  252. .hoverCard(cornerRadius: 8, isSelected: isSelected)
  253. .hoverCursor()
  254. .onTapGesture(perform: onApply)
  255. }
  256. }
  257. // MARK: - Compare Card
  258. struct TitleCompareCard: View {
  259. let title: String
  260. let score: Int?
  261. let label: String
  262. let isOriginal: Bool
  263. var isSelected: Bool = false
  264. var body: some View {
  265. VStack(alignment: .leading, spacing: 10) {
  266. HStack {
  267. Text(label)
  268. .font(.system(size: 10, weight: .semibold))
  269. .foregroundStyle(isOriginal ? AppTheme.textSecondary : AppTheme.accentBlue)
  270. .padding(.horizontal, 8)
  271. .padding(.vertical, 3)
  272. .background(
  273. Capsule()
  274. .fill(
  275. isOriginal
  276. ? AppTheme.panelBackground
  277. : AppTheme.accentBlue.opacity(0.15)
  278. )
  279. )
  280. Spacer()
  281. if let score {
  282. TitleScoreBadge(score: score)
  283. }
  284. }
  285. Text(title.isEmpty ? "No title" : title)
  286. .font(.system(size: 13, weight: .semibold))
  287. .foregroundStyle(title.isEmpty ? AppTheme.textTertiary : AppTheme.textPrimary)
  288. .fixedSize(horizontal: false, vertical: true)
  289. HStack {
  290. Text("\(title.count) / 300 characters")
  291. .font(.system(size: 9))
  292. .foregroundStyle(
  293. title.count > 300 ? Color(hex: 0xF87171) : AppTheme.textTertiary
  294. )
  295. Spacer()
  296. if isSelected {
  297. Label("Active", systemImage: "checkmark.circle.fill")
  298. .font(.system(size: 9, weight: .semibold))
  299. .foregroundStyle(AppTheme.accentGreen)
  300. }
  301. }
  302. }
  303. .padding(14)
  304. .frame(maxWidth: .infinity, alignment: .leading)
  305. .background(
  306. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  307. .fill(AppTheme.cardBackground)
  308. .overlay(
  309. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  310. .stroke(
  311. isSelected ? AppTheme.accentBlue.opacity(0.5) : AppTheme.cardBorder,
  312. lineWidth: isSelected ? 1.5 : 1
  313. )
  314. )
  315. )
  316. .hoverCard(cornerRadius: AppTheme.cornerRadiusSmall, isSelected: isSelected)
  317. .hoverCursor()
  318. }
  319. }
  320. // MARK: - Feed Preview Row
  321. struct TitleFeedPreviewRow: View {
  322. let title: String
  323. let subreddit: String
  324. var isHighlighted: Bool = false
  325. var body: some View {
  326. HStack(alignment: .top, spacing: 10) {
  327. RoundedRectangle(cornerRadius: 4)
  328. .fill(AppTheme.cardBackground)
  329. .frame(width: 72, height: 52)
  330. .overlay {
  331. Image(systemName: "photo")
  332. .font(.system(size: 16))
  333. .foregroundStyle(AppTheme.textTertiary)
  334. }
  335. VStack(alignment: .leading, spacing: 4) {
  336. Text(subreddit)
  337. .font(.system(size: 9, weight: .semibold))
  338. .foregroundStyle(AppTheme.textTertiary)
  339. Text(title.isEmpty ? "Title preview" : title)
  340. .font(.system(size: 11, weight: .semibold))
  341. .foregroundStyle(title.isEmpty ? AppTheme.textTertiary : AppTheme.textPrimary)
  342. .lineLimit(2)
  343. .fixedSize(horizontal: false, vertical: true)
  344. }
  345. Spacer(minLength: 0)
  346. }
  347. .padding(10)
  348. .background(
  349. RoundedRectangle(cornerRadius: 8)
  350. .fill(isHighlighted ? AppTheme.accentBlue.opacity(0.08) : AppTheme.panelBackground)
  351. )
  352. .overlay(
  353. RoundedRectangle(cornerRadius: 8)
  354. .stroke(
  355. isHighlighted ? AppTheme.accentBlue.opacity(0.4) : AppTheme.cardBorder,
  356. lineWidth: 1
  357. )
  358. )
  359. }
  360. }
  361. // MARK: - Empty State
  362. struct TitleOptimizerEmptyState: View {
  363. let icon: String
  364. let title: String
  365. let subtitle: String
  366. var body: some View {
  367. VStack(spacing: 10) {
  368. Image(systemName: icon)
  369. .font(.system(size: 28))
  370. .foregroundStyle(AppTheme.accentBlue.opacity(0.6))
  371. Text(title)
  372. .font(.system(size: 12, weight: .semibold))
  373. .foregroundStyle(AppTheme.textSecondary)
  374. Text(subtitle)
  375. .font(.system(size: 10))
  376. .foregroundStyle(AppTheme.textTertiary)
  377. .multilineTextAlignment(.center)
  378. }
  379. .frame(maxWidth: .infinity)
  380. .padding(.vertical, 32)
  381. .background(
  382. RoundedRectangle(cornerRadius: 8)
  383. .fill(AppTheme.panelBackground)
  384. .overlay(
  385. RoundedRectangle(cornerRadius: 8)
  386. .strokeBorder(
  387. AppTheme.accentBlue.opacity(0.2),
  388. style: StrokeStyle(lineWidth: 1, dash: [6, 4])
  389. )
  390. )
  391. )
  392. }
  393. }