/ RacerTracer / Views / ImageAnalysisView.swift
ImageAnalysisView.swift
  1  //
  2  //  ImageAnalysisView.swift
  3  //  RacerTracer
  4  //
  5  //  Created by Alexander Kunau on 29.09.25.
  6  //
  7  
  8  import SwiftUI
  9  import Charts
 10  
 11  struct ImageAnalysisView: View {
 12      @StateObject private var analysisService = ImageAnalysisService()
 13      @EnvironmentObject private var settingsService: SettingsService
 14      @State private var urlToAnalyze = ""
 15      @State private var selectedFilter = "All Images"
 16      @State private var selectedFormat = "All Formats"
 17      
 18      var body: some View {
 19          NavigationView {
 20              VStack(spacing: 0) {
 21                  // Header Section
 22                  VStack(alignment: .leading, spacing: 16) {
 23                      Text("Image SEO Analysis")
 24                          .font(.title2)
 25                          .fontWeight(.semibold)
 26                      
 27                      Text("Analyze images for SEO best practices: alt texts, file sizes, formats, and accessibility compliance.")
 28                          .font(.subheadline)
 29                          .foregroundColor(.secondary)
 30                  }
 31                  .frame(maxWidth: .infinity, alignment: .leading)
 32                  .padding(24)
 33                  .background(Color(NSColor.controlBackgroundColor))
 34                  
 35                  // Analysis Input Section
 36                  VStack(spacing: 16) {
 37                      HStack {
 38                          TextField("Enter website URL to analyze images", text: $urlToAnalyze)
 39                              .textFieldStyle(RoundedBorderTextFieldStyle())
 40                          
 41                          Button(action: {
 42                              Task {
 43                                  await analysisService.analyzeImages(url: urlToAnalyze)
 44                              }
 45                          }) {
 46                              if analysisService.isAnalyzing {
 47                                  ProgressView()
 48                                      .scaleEffect(0.8)
 49                              } else {
 50                                  Text("Analyze")
 51                              }
 52                          }
 53                          .disabled(urlToAnalyze.isEmpty || analysisService.isAnalyzing)
 54                          .buttonStyle(.borderedProminent)
 55                      }
 56                      
 57                      if analysisService.isAnalyzing {
 58                          ProgressView("Analyzing images and alt texts...", value: analysisService.progress, total: 1.0)
 59                              .progressViewStyle(LinearProgressViewStyle())
 60                      }
 61                  }
 62                  .padding(.horizontal, 24)
 63                  .padding(.bottom, 16)
 64                  
 65                  // Content Area
 66                  ScrollView {
 67                      if let analysis = analysisService.currentAnalysis {
 68                          VStack(spacing: 24) {
 69                              // Overview Cards
 70                              ImageOverviewCardsSection(analysis: analysis)
 71                              
 72                              // Filters
 73                              ImageFiltersSection(
 74                                  selectedFilter: $selectedFilter,
 75                                  selectedFormat: $selectedFormat,
 76                                  formats: analysis.imageFormats
 77                              )
 78                              
 79                              // Charts Section
 80                              ImageChartsSection(analysis: analysis)
 81                              
 82                              // Missing Alt Text Section
 83                              if !analysis.imagesWithoutAltText.isEmpty {
 84                                  MissingAltTextSection(images: analysis.imagesWithoutAltText)
 85                              }
 86                              
 87                              // Large Images Section
 88                              if !analysis.oversizedImages.isEmpty {
 89                                  OversizedImagesSection(images: analysis.oversizedImages)
 90                              }
 91                              
 92                              // Image Format Analysis
 93                              ImageFormatAnalysisSection(analysis: analysis)
 94                              
 95                              // Images List
 96                              ImagesListSection(
 97                                  images: filteredImages(analysis.images),
 98                                  selectedFilter: selectedFilter,
 99                                  selectedFormat: selectedFormat
100                              )
101                              
102                              // SEO Recommendations
103                              ImageSEORecommendationsSection(analysis: analysis)
104                          }
105                          .padding(24)
106                      } else if !analysisService.isAnalyzing {
107                          ImageEmptyStateView()
108                      }
109                  }
110              }
111          }
112          .navigationTitle("Image SEO Analysis")
113      }
114      
115      private func filteredImages(_ images: [ImageItem]) -> [ImageItem] {
116          var filtered = images
117          
118          switch selectedFilter {
119          case "Missing Alt Text":
120              filtered = filtered.filter { $0.altText.isEmpty }
121          case "Has Alt Text":
122              filtered = filtered.filter { !$0.altText.isEmpty }
123          case "Oversized":
124              filtered = filtered.filter { $0.fileSize > 100000 } // > 100KB
125          case "Optimized":
126              filtered = filtered.filter { $0.fileSize <= 100000 && !$0.altText.isEmpty }
127          default:
128              break
129          }
130          
131          if selectedFormat != "All Formats" {
132              filtered = filtered.filter { $0.format.lowercased() == selectedFormat.lowercased() }
133          }
134          
135          return filtered
136      }
137  }
138  
139  // MARK: - Image Overview Cards Section
140  
141  struct ImageOverviewCardsSection: View {
142      let analysis: ImageAnalysis
143      
144      var body: some View {
145          VStack(alignment: .leading, spacing: 16) {
146              Text("Image SEO Overview")
147                  .font(.headline)
148                  .fontWeight(.semibold)
149              
150              LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 16) {
151                  OverviewCard(
152                      title: "Total Images",
153                      value: "\(analysis.totalImages)",
154                      subtitle: "Found on website",
155                      color: .blue,
156                      icon: "photo"
157                  )
158                  
159                  OverviewCard(
160                      title: "Missing Alt Text",
161                      value: "\(analysis.imagesWithoutAltText.count)",
162                      subtitle: "Accessibility issues",
163                      color: .red,
164                      icon: "exclamationmark.triangle"
165                  )
166                  
167                  OverviewCard(
168                      title: "Alt Text Coverage",
169                      value: String(format: "%.0f%%", analysis.altTextCoverage * 100),
170                      subtitle: "Has alt text",
171                      color: analysis.altTextCoverage > 0.8 ? .green : analysis.altTextCoverage > 0.5 ? .orange : .red,
172                      icon: "text.quote"
173                  )
174                  
175                  OverviewCard(
176                      title: "Average File Size",
177                      value: formatFileSize(analysis.averageFileSize),
178                      subtitle: "Per image",
179                      color: analysis.averageFileSize < 50000 ? .green : analysis.averageFileSize < 100000 ? .orange : .red,
180                      icon: "doc"
181                  )
182              }
183              
184              // Additional metrics row
185              LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 16) {
186                  OverviewCard(
187                      title: "Oversized Images",
188                      value: "\(analysis.oversizedImages.count)",
189                      subtitle: "> 100KB",
190                      color: .orange,
191                      icon: "exclamationmark.circle"
192                  )
193                  
194                  OverviewCard(
195                      title: "Total Size",
196                      value: formatFileSize(analysis.totalImageSize),
197                      subtitle: "All images",
198                      color: .purple,
199                      icon: "tray.full"
200                  )
201                  
202                  OverviewCard(
203                      title: "SEO Score",
204                      value: String(format: "%.0f%%", analysis.seoScore * 100),
205                      subtitle: "Image optimization",
206                      color: analysis.seoScore > 0.8 ? .green : analysis.seoScore > 0.6 ? .orange : .red,
207                      icon: "checkmark.shield"
208                  )
209              }
210          }
211      }
212      
213      private func formatFileSize(_ bytes: Int) -> String {
214          let formatter = ByteCountFormatter()
215          formatter.allowedUnits = [.useKB, .useMB]
216          formatter.countStyle = .file
217          return formatter.string(fromByteCount: Int64(bytes))
218      }
219  }
220  
221  // MARK: - Image Charts Section
222  
223  struct ImageChartsSection: View {
224      let analysis: ImageAnalysis
225      
226      var body: some View {
227          VStack(alignment: .leading, spacing: 20) {
228              Text("Image Analysis Charts")
229                  .font(.headline)
230                  .fontWeight(.semibold)
231              
232              HStack(spacing: 20) {
233                  // Format Distribution Chart
234                  VStack(alignment: .leading, spacing: 12) {
235                      Text("Image Formats")
236                          .font(.subheadline)
237                          .fontWeight(.medium)
238                      
239                      Chart(analysis.formatDistribution) { item in
240                          SectorMark(
241                              angle: .value("Count", item.count),
242                              innerRadius: .ratio(0.5),
243                              angularInset: 2
244                          )
245                          .foregroundStyle(item.format.color.gradient)
246                          .cornerRadius(3)
247                      }
248                      .frame(height: 180)
249                  }
250                  .frame(maxWidth: .infinity)
251                  
252                  // File Size Distribution Chart
253                  VStack(alignment: .leading, spacing: 12) {
254                      Text("File Size Distribution")
255                          .font(.subheadline)
256                          .fontWeight(.medium)
257                      
258                      Chart(analysis.sizeDistribution) { item in
259                          BarMark(
260                              x: .value("Size Range", item.range),
261                              y: .value("Count", item.count)
262                          )
263                          .foregroundStyle(.blue.gradient)
264                          .cornerRadius(4)
265                      }
266                      .frame(height: 180)
267                      .chartXAxis {
268                          AxisMarks { _ in
269                              AxisValueLabel()
270                                  .font(.caption)
271                          }
272                      }
273                  }
274                  .frame(maxWidth: .infinity)
275              }
276              
277              // Alt Text Quality Chart
278              VStack(alignment: .leading, spacing: 12) {
279                  Text("Alt Text Quality Distribution")
280                      .font(.subheadline)
281                      .fontWeight(.medium)
282                  
283                  Chart(analysis.altTextQuality) { item in
284                      BarMark(
285                          x: .value("Quality", item.quality.rawValue),
286                          y: .value("Count", item.count)
287                      )
288                      .foregroundStyle(item.quality.color.gradient)
289                      .cornerRadius(4)
290                  }
291                  .frame(height: 150)
292              }
293          }
294          .padding(20)
295          .background(Color(NSColor.controlBackgroundColor))
296          .cornerRadius(12)
297      }
298  }
299  
300  // MARK: - Missing Alt Text Section
301  
302  struct MissingAltTextSection: View {
303      let images: [ImageItem]
304      
305      var body: some View {
306          VStack(alignment: .leading, spacing: 16) {
307              HStack {
308                  Image(systemName: "exclamationmark.triangle.fill")
309                      .foregroundColor(.red)
310                  Text("Images Missing Alt Text")
311                      .font(.headline)
312                      .fontWeight(.semibold)
313                  
314                  Spacer()
315                  
316                  Text("\(images.count) issues")
317                      .font(.subheadline)
318                      .foregroundColor(.red)
319                      .padding(.horizontal, 12)
320                      .padding(.vertical, 4)
321                      .background(.red.opacity(0.1))
322                      .cornerRadius(8)
323              }
324              
325              LazyVStack(spacing: 8) {
326                  ForEach(images.prefix(10)) { image in
327                      MissingAltTextRow(image: image)
328                  }
329                  
330                  if images.count > 10 {
331                      Text("... and \(images.count - 10) more images without alt text")
332                          .font(.caption)
333                          .foregroundColor(.secondary)
334                          .padding(.top, 8)
335                  }
336              }
337          }
338          .padding(20)
339          .background(Color(NSColor.controlBackgroundColor))
340          .cornerRadius(12)
341      }
342  }
343  
344  struct MissingAltTextRow: View {
345      let image: ImageItem
346      
347      var body: some View {
348          HStack {
349              // Image thumbnail placeholder
350              RoundedRectangle(cornerRadius: 8)
351                  .fill(.gray.opacity(0.3))
352                  .frame(width: 60, height: 60)
353                  .overlay(
354                      Image(systemName: "photo")
355                          .foregroundColor(.gray)
356                  )
357              
358              VStack(alignment: .leading, spacing: 4) {
359                  Text(image.filename)
360                      .font(.subheadline)
361                      .fontWeight(.medium)
362                      .lineLimit(1)
363                  
364                  Text(image.url)
365                      .font(.caption)
366                      .foregroundColor(.secondary)
367                      .lineLimit(1)
368                  
369                  HStack {
370                      Text(image.format.uppercased())
371                          .font(.caption)
372                          .fontWeight(.medium)
373                          .foregroundColor(.white)
374                          .padding(.horizontal, 6)
375                          .padding(.vertical, 2)
376                          .background(.blue)
377                          .cornerRadius(4)
378                      
379                      Text(formatFileSize(image.fileSize))
380                          .font(.caption)
381                          .foregroundColor(.secondary)
382                  }
383              }
384              
385              Spacer()
386              
387              VStack(alignment: .trailing, spacing: 4) {
388                  Text("NO ALT TEXT")
389                      .font(.caption)
390                      .fontWeight(.bold)
391                      .foregroundColor(.white)
392                      .padding(.horizontal, 8)
393                      .padding(.vertical, 4)
394                      .background(.red)
395                      .cornerRadius(6)
396                  
397                  Text("\(image.width)×\(image.height)")
398                      .font(.caption)
399                      .foregroundColor(.secondary)
400              }
401          }
402          .padding(12)
403          .background(.red.opacity(0.05))
404          .cornerRadius(8)
405      }
406      
407      private func formatFileSize(_ bytes: Int) -> String {
408          let formatter = ByteCountFormatter()
409          formatter.allowedUnits = [.useKB, .useMB]
410          formatter.countStyle = .file
411          return formatter.string(fromByteCount: Int64(bytes))
412      }
413  }
414  
415  // MARK: - Oversized Images Section
416  
417  struct OversizedImagesSection: View {
418      let images: [ImageItem]
419      
420      var body: some View {
421          VStack(alignment: .leading, spacing: 16) {
422              HStack {
423                  Image(systemName: "exclamationmark.circle.fill")
424                      .foregroundColor(.orange)
425                  Text("Oversized Images")
426                      .font(.headline)
427                      .fontWeight(.semibold)
428                  
429                  Spacer()
430                  
431                  Text("\(images.count) images")
432                      .font(.subheadline)
433                      .foregroundColor(.orange)
434                      .padding(.horizontal, 12)
435                      .padding(.vertical, 4)
436                      .background(.orange.opacity(0.1))
437                      .cornerRadius(8)
438              }
439              
440              LazyVStack(spacing: 8) {
441                  ForEach(images.prefix(10)) { image in
442                      OversizedImageRow(image: image)
443                  }
444                  
445                  if images.count > 10 {
446                      Text("... and \(images.count - 10) more oversized images")
447                          .font(.caption)
448                          .foregroundColor(.secondary)
449                          .padding(.top, 8)
450                  }
451              }
452          }
453          .padding(20)
454          .background(Color(NSColor.controlBackgroundColor))
455          .cornerRadius(12)
456      }
457  }
458  
459  struct OversizedImageRow: View {
460      let image: ImageItem
461      
462      var body: some View {
463          HStack {
464              RoundedRectangle(cornerRadius: 8)
465                  .fill(.gray.opacity(0.3))
466                  .frame(width: 60, height: 60)
467                  .overlay(
468                      Image(systemName: "photo")
469                          .foregroundColor(.gray)
470                  )
471              
472              VStack(alignment: .leading, spacing: 4) {
473                  Text(image.filename)
474                      .font(.subheadline)
475                      .fontWeight(.medium)
476                      .lineLimit(1)
477                  
478                  Text(image.url)
479                      .font(.caption)
480                      .foregroundColor(.secondary)
481                      .lineLimit(1)
482                  
483                  if !image.altText.isEmpty {
484                      Text("Alt: \(image.altText)")
485                          .font(.caption)
486                          .foregroundColor(.secondary)
487                          .lineLimit(1)
488                  }
489              }
490              
491              Spacer()
492              
493              VStack(alignment: .trailing, spacing: 4) {
494                  Text(formatFileSize(image.fileSize))
495                      .font(.subheadline)
496                      .fontWeight(.bold)
497                      .foregroundColor(.orange)
498                  
499                  Text("\(image.width)×\(image.height)")
500                      .font(.caption)
501                      .foregroundColor(.secondary)
502                  
503                  Text(image.format.uppercased())
504                      .font(.caption)
505                      .foregroundColor(.secondary)
506              }
507          }
508          .padding(12)
509          .background(.orange.opacity(0.05))
510          .cornerRadius(8)
511      }
512      
513      private func formatFileSize(_ bytes: Int) -> String {
514          let formatter = ByteCountFormatter()
515          formatter.allowedUnits = [.useKB, .useMB]
516          formatter.countStyle = .file
517          return formatter.string(fromByteCount: Int64(bytes))
518      }
519  }
520  
521  // MARK: - Supporting Views
522  
523  struct ImageFiltersSection: View {
524      @Binding var selectedFilter: String
525      @Binding var selectedFormat: String
526      let formats: [String]
527      
528      var body: some View {
529          VStack(alignment: .leading, spacing: 16) {
530              Text("Filters")
531                  .font(.headline)
532                  .fontWeight(.semibold)
533              
534              HStack(spacing: 20) {
535                  VStack(alignment: .leading, spacing: 8) {
536                      Text("Filter by Issue")
537                          .font(.subheadline)
538                          .fontWeight(.medium)
539                      
540                      Picker("Filter", selection: $selectedFilter) {
541                          Text("All Images").tag("All Images")
542                          Text("Missing Alt Text").tag("Missing Alt Text")
543                          Text("Has Alt Text").tag("Has Alt Text")
544                          Text("Oversized").tag("Oversized")
545                          Text("Optimized").tag("Optimized")
546                      }
547                      .pickerStyle(MenuPickerStyle())
548                  }
549                  
550                  VStack(alignment: .leading, spacing: 8) {
551                      Text("Format")
552                          .font(.subheadline)
553                          .fontWeight(.medium)
554                      
555                      Picker("Format", selection: $selectedFormat) {
556                          Text("All Formats").tag("All Formats")
557                          ForEach(formats, id: \.self) { format in
558                              Text(format.uppercased()).tag(format)
559                          }
560                      }
561                      .pickerStyle(MenuPickerStyle())
562                  }
563                  
564                  Spacer()
565              }
566          }
567          .padding(20)
568          .background(Color(NSColor.controlBackgroundColor))
569          .cornerRadius(12)
570      }
571  }
572  
573  struct ImageFormatAnalysisSection: View {
574      let analysis: ImageAnalysis
575      
576      var body: some View {
577          VStack(alignment: .leading, spacing: 16) {
578              Text("Format Recommendations")
579                  .font(.headline)
580                  .fontWeight(.semibold)
581              
582              LazyVStack(spacing: 12) {
583                  ForEach(analysis.formatRecommendations, id: \.format) { recommendation in
584                      ImageFormatRecommendationCard(recommendation: recommendation)
585                  }
586              }
587          }
588          .padding(20)
589          .background(Color(NSColor.controlBackgroundColor))
590          .cornerRadius(12)
591      }
592  }
593  
594  struct ImageFormatRecommendationCard: View {
595      let recommendation: ImageFormatRecommendation
596      
597      var body: some View {
598          HStack {
599              Image(systemName: recommendation.format.icon)
600                  .foregroundColor(recommendation.format.color)
601                  .font(.title2)
602              
603              VStack(alignment: .leading, spacing: 4) {
604                  Text("\(recommendation.format.uppercased()) Format")
605                      .font(.subheadline)
606                      .fontWeight(.medium)
607                  
608                  Text(recommendation.description)
609                      .font(.caption)
610                      .foregroundColor(.secondary)
611              }
612              
613              Spacer()
614              
615              VStack(alignment: .trailing, spacing: 4) {
616                  Text("\(recommendation.count) images")
617                      .font(.subheadline)
618                      .fontWeight(.semibold)
619                  
620                  Text(recommendation.recommendation)
621                      .font(.caption)
622                      .foregroundColor(recommendation.priority.color)
623                      .fontWeight(.medium)
624              }
625          }
626          .padding(16)
627          .background(Color(NSColor.textBackgroundColor))
628          .cornerRadius(10)
629      }
630  }
631  
632  struct ImagesListSection: View {
633      let images: [ImageItem]
634      let selectedFilter: String
635      let selectedFormat: String
636      
637      var body: some View {
638          VStack(alignment: .leading, spacing: 16) {
639              HStack {
640                  Text("Images List")
641                      .font(.headline)
642                      .fontWeight(.semibold)
643                  
644                  Spacer()
645                  
646                  Text("\(images.count) images")
647                      .font(.subheadline)
648                      .foregroundColor(.secondary)
649              }
650              
651              LazyVStack(spacing: 8) {
652                  ForEach(images.prefix(50)) { image in
653                      ImageRow(image: image)
654                  }
655                  
656                  if images.count > 50 {
657                      Text("... and \(images.count - 50) more images")
658                          .font(.caption)
659                          .foregroundColor(.secondary)
660                          .padding(.top, 8)
661                  }
662              }
663          }
664          .padding(20)
665          .background(Color(NSColor.controlBackgroundColor))
666          .cornerRadius(12)
667      }
668  }
669  
670  struct ImageRow: View {
671      let image: ImageItem
672      
673      var body: some View {
674          HStack {
675              RoundedRectangle(cornerRadius: 8)
676                  .fill(.gray.opacity(0.3))
677                  .frame(width: 50, height: 50)
678                  .overlay(
679                      Image(systemName: "photo")
680                          .foregroundColor(.gray)
681                          .font(.caption)
682                  )
683              
684              VStack(alignment: .leading, spacing: 4) {
685                  Text(image.filename)
686                      .font(.subheadline)
687                      .fontWeight(.medium)
688                      .lineLimit(1)
689                  
690                  if !image.altText.isEmpty {
691                      Text("Alt: \(image.altText)")
692                          .font(.caption)
693                          .foregroundColor(.secondary)
694                          .lineLimit(2)
695                  } else {
696                      Text("No alt text")
697                          .font(.caption)
698                          .foregroundColor(.red)
699                          .italic()
700                  }
701              }
702              
703              Spacer()
704              
705              VStack(alignment: .trailing, spacing: 4) {
706                  Text(formatFileSize(image.fileSize))
707                      .font(.caption)
708                      .fontWeight(.medium)
709                      .foregroundColor(image.fileSize > 100000 ? .orange : .secondary)
710                  
711                  Text("\(image.width)×\(image.height)")
712                      .font(.caption)
713                      .foregroundColor(.secondary)
714                  
715                  Text(image.format.uppercased())
716                      .font(.caption)
717                      .fontWeight(.medium)
718                      .foregroundColor(.white)
719                      .padding(.horizontal, 6)
720                      .padding(.vertical, 2)
721                      .background(.blue)
722                      .cornerRadius(4)
723              }
724          }
725          .padding(10)
726          .background(Color(NSColor.textBackgroundColor))
727          .cornerRadius(8)
728      }
729      
730      private func formatFileSize(_ bytes: Int) -> String {
731          let formatter = ByteCountFormatter()
732          formatter.allowedUnits = [.useKB, .useMB]
733          formatter.countStyle = .file
734          return formatter.string(fromByteCount: Int64(bytes))
735      }
736  }
737  
738  struct ImageSEORecommendationsSection: View {
739      let analysis: ImageAnalysis
740      
741      var body: some View {
742          VStack(alignment: .leading, spacing: 16) {
743              Text("Image SEO Recommendations")
744                  .font(.headline)
745                  .fontWeight(.semibold)
746              
747              LazyVStack(spacing: 12) {
748                  ForEach(analysis.seoRecommendations, id: \.title) { recommendation in
749                      CommonSEORecommendationCard(recommendation: recommendation)
750                  }
751              }
752          }
753          .padding(20)
754          .background(Color(NSColor.controlBackgroundColor))
755          .cornerRadius(12)
756      }
757  }
758  
759  struct ImageEmptyStateView: View {
760      var body: some View {
761          VStack(spacing: 16) {
762              Image(systemName: "photo")
763                  .font(.system(size: 64))
764                  .foregroundColor(.secondary)
765              
766              Text("No Image Analysis Yet")
767                  .font(.title2)
768                  .fontWeight(.semibold)
769              
770              Text("Enter a website URL above to start analyzing images for SEO best practices, alt texts, and optimization opportunities.")
771                  .font(.subheadline)
772                  .foregroundColor(.secondary)
773                  .multilineTextAlignment(.center)
774                  .padding(.horizontal, 40)
775          }
776          .frame(maxWidth: .infinity, maxHeight: .infinity)
777      }
778  }
779  
780  #Preview {
781      ImageAnalysisView()
782          .environmentObject(SettingsService.shared)
783  }