AIHistoryView.swift 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import SwiftUI
  2. struct AIHistoryView: View {
  3. @Bindable var viewModel: AIHistoryViewModel
  4. var onOpen: (AIHistoryEntry) -> Void
  5. private enum Layout {
  6. static let horizontalPadding: CGFloat = 24
  7. }
  8. var body: some View {
  9. VStack(spacing: 0) {
  10. header
  11. filterBar
  12. if viewModel.isEmpty {
  13. emptyState
  14. } else if !viewModel.hasFilteredEntries {
  15. filteredEmptyState
  16. } else {
  17. historyList
  18. }
  19. }
  20. .background(AppTheme.background)
  21. .alert("Delete this item?", isPresented: deleteBinding) {
  22. Button("Cancel", role: .cancel) {
  23. viewModel.cancelDelete()
  24. }
  25. Button("Delete", role: .destructive) {
  26. viewModel.confirmDelete()
  27. }
  28. } message: {
  29. if let entry = viewModel.entryPendingDeletion {
  30. Text("Remove \"\(entry.displayTitle)\" from history?")
  31. }
  32. }
  33. .alert("Delete all history?", isPresented: $viewModel.showDeleteAllConfirmation) {
  34. Button("Cancel", role: .cancel) {
  35. viewModel.cancelDeleteAll()
  36. }
  37. Button("Delete All", role: .destructive) {
  38. viewModel.confirmDeleteAll()
  39. }
  40. } message: {
  41. Text("This permanently removes all saved posts, titles, and comments.")
  42. }
  43. }
  44. private var deleteBinding: Binding<Bool> {
  45. Binding(
  46. get: { viewModel.entryPendingDeletion != nil },
  47. set: { isPresented in
  48. if !isPresented {
  49. viewModel.cancelDelete()
  50. }
  51. }
  52. )
  53. }
  54. private var header: some View {
  55. VStack(spacing: 0) {
  56. HStack(spacing: 14) {
  57. ModernSidebarIconView(kind: .history, size: 40)
  58. VStack(alignment: .leading, spacing: 2) {
  59. Text("History")
  60. .font(.system(size: 18, weight: .semibold))
  61. .foregroundStyle(AppTheme.textPrimary)
  62. Text("Saved AI responses from your tools")
  63. .font(.system(size: 11))
  64. .foregroundStyle(AppTheme.textSecondary)
  65. }
  66. Spacer()
  67. if !viewModel.isEmpty {
  68. Button {
  69. viewModel.requestDeleteAll()
  70. } label: {
  71. Label("Delete All", systemImage: "trash")
  72. .font(.system(size: 11, weight: .medium))
  73. }
  74. .buttonStyle(AppSecondaryButtonStyle())
  75. }
  76. }
  77. .padding(.horizontal, Layout.horizontalPadding)
  78. .padding(.vertical, 16)
  79. FullWidthDivider()
  80. }
  81. }
  82. private var filterBar: some View {
  83. ScrollView(.horizontal, showsIndicators: false) {
  84. HStack(spacing: 6) {
  85. ForEach(AIHistoryFilter.allCases) { filter in
  86. filterChip(filter)
  87. }
  88. }
  89. .padding(.horizontal, Layout.horizontalPadding)
  90. .padding(.vertical, 12)
  91. }
  92. }
  93. private func filterChip(_ filter: AIHistoryFilter) -> some View {
  94. let isSelected = viewModel.selectedFilter == filter
  95. return Button {
  96. withAnimation(.easeInOut(duration: 0.15)) {
  97. viewModel.selectedFilter = filter
  98. }
  99. } label: {
  100. Text(filter.title)
  101. .font(.system(size: 11, weight: isSelected ? .bold : .medium))
  102. .foregroundStyle(isSelected ? Color(hex: 0xD97706) : AppTheme.textSecondary)
  103. .padding(.horizontal, 12)
  104. .padding(.vertical, 7)
  105. .background(
  106. RoundedRectangle(cornerRadius: 8, style: .continuous)
  107. .fill(isSelected ? Color(hex: 0xD97706).opacity(0.12) : AppTheme.cardBackground)
  108. )
  109. .overlay(
  110. RoundedRectangle(cornerRadius: 8, style: .continuous)
  111. .stroke(
  112. isSelected ? Color(hex: 0xD97706).opacity(0.5) : AppTheme.cardBorder,
  113. lineWidth: isSelected ? 1.5 : 1
  114. )
  115. )
  116. }
  117. .buttonStyle(.plain)
  118. .hoverCursor()
  119. }
  120. private var historyList: some View {
  121. ScrollView {
  122. LazyVStack(spacing: 8) {
  123. ForEach(viewModel.filteredEntries) { entry in
  124. AIHistoryRowView(
  125. entry: entry,
  126. onOpen: { onOpen(entry) },
  127. onDelete: { viewModel.requestDelete(entry) }
  128. )
  129. }
  130. }
  131. .padding(.horizontal, Layout.horizontalPadding)
  132. .padding(.bottom, 24)
  133. }
  134. }
  135. private var emptyState: some View {
  136. VStack(spacing: 12) {
  137. Image(systemName: "clock.arrow.circlepath")
  138. .font(.system(size: 32))
  139. .foregroundStyle(Color(hex: 0xD97706).opacity(0.7))
  140. Text("No history yet")
  141. .font(.system(size: 15, weight: .semibold))
  142. .foregroundStyle(AppTheme.textPrimary)
  143. Text("Generated posts, optimized titles, and comments\nwill appear here automatically.")
  144. .font(.system(size: 12))
  145. .foregroundStyle(AppTheme.textSecondary)
  146. .multilineTextAlignment(.center)
  147. }
  148. .frame(maxWidth: .infinity, maxHeight: .infinity)
  149. }
  150. private var filteredEmptyState: some View {
  151. VStack(spacing: 12) {
  152. Image(systemName: "line.3.horizontal.decrease.circle")
  153. .font(.system(size: 28))
  154. .foregroundStyle(AppTheme.textTertiary)
  155. Text("No \(viewModel.selectedFilter.title.lowercased()) in history")
  156. .font(.system(size: 14, weight: .semibold))
  157. .foregroundStyle(AppTheme.textPrimary)
  158. Text("Try another filter or generate content with the AI tools.")
  159. .font(.system(size: 12))
  160. .foregroundStyle(AppTheme.textSecondary)
  161. }
  162. .frame(maxWidth: .infinity, maxHeight: .infinity)
  163. }
  164. }
  165. private struct AIHistoryRowView: View {
  166. let entry: AIHistoryEntry
  167. var onOpen: () -> Void
  168. var onDelete: () -> Void
  169. var body: some View {
  170. HStack(alignment: .top, spacing: 12) {
  171. ModernSidebarIconView(kind: entry.toolKind.iconKind, size: 36)
  172. VStack(alignment: .leading, spacing: 4) {
  173. HStack(spacing: 6) {
  174. Text(entry.toolKind.title)
  175. .font(.system(size: 9, weight: .semibold))
  176. .foregroundStyle(AppTheme.textTertiary)
  177. .textCase(.uppercase)
  178. .tracking(0.3)
  179. Text("·")
  180. .foregroundStyle(AppTheme.textTertiary)
  181. Text(entry.createdAt, style: .relative)
  182. .font(.system(size: 9))
  183. .foregroundStyle(AppTheme.textTertiary)
  184. }
  185. Text(entry.displayTitle)
  186. .font(.system(size: 13, weight: .semibold))
  187. .foregroundStyle(AppTheme.textPrimary)
  188. .lineLimit(2)
  189. .multilineTextAlignment(.leading)
  190. Text(entry.displaySubtitle)
  191. .font(.system(size: 11))
  192. .foregroundStyle(AppTheme.textSecondary)
  193. }
  194. Spacer(minLength: 8)
  195. HStack(spacing: 6) {
  196. Button(action: onOpen) {
  197. Label("Open", systemImage: "arrow.up.right.square")
  198. .font(.system(size: 10, weight: .medium))
  199. .labelStyle(.titleAndIcon)
  200. }
  201. .buttonStyle(AppSecondaryButtonStyle())
  202. Button(action: onDelete) {
  203. Image(systemName: "trash")
  204. .font(.system(size: 10, weight: .medium))
  205. }
  206. .buttonStyle(AppSecondaryButtonStyle())
  207. }
  208. }
  209. .padding(14)
  210. .background(
  211. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  212. .fill(AppTheme.cardBackground)
  213. .overlay(
  214. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  215. .stroke(AppTheme.cardBorder, lineWidth: 1)
  216. )
  217. )
  218. }
  219. }
  220. #Preview {
  221. AIHistoryView(viewModel: AIHistoryViewModel()) { _ in }
  222. .frame(width: 880, height: 720)
  223. }