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 }