TitleOptimizerComponents.swift 14 KB

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