URLAnalysisView.swift
1 // 2 // URLAnalysisView.swift 3 // RacerTracer 4 // 5 // Created by Alexander Kunau on 29.09.25. 6 // 7 8 import SwiftUI 9 import Charts 10 11 struct URLAnalysisView: View { 12 @EnvironmentObject var seoService: SEOAnalysisService 13 @State private var urlToAnalyze = "" 14 @State private var showingAnalysisResults = false 15 @State private var errorAlert = false 16 @State private var errorMessage = "" 17 18 var body: some View { 19 VStack(spacing: 24) { 20 // URL Input Section 21 URLInputSection( 22 urlToAnalyze: $urlToAnalyze, 23 onAnalyze: performAnalysis 24 ) 25 26 // Analysis Progress 27 if seoService.isAnalyzing { 28 AnalysisProgressView() 29 } 30 31 // Results Section 32 if let analysis = seoService.currentAnalysis, !seoService.isAnalyzing { 33 AnalysisResultsView(analysis: analysis) 34 } else if !seoService.isAnalyzing { 35 EmptyAnalysisView() 36 } 37 } 38 .padding(24) 39 .navigationTitle("URL Analysis") 40 .alert("Analysis Error", isPresented: $errorAlert) { 41 Button("OK") { } 42 } message: { 43 Text(errorMessage) 44 } 45 } 46 47 private func performAnalysis() { 48 guard !urlToAnalyze.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { 49 errorMessage = "Please enter a valid URL" 50 errorAlert = true 51 return 52 } 53 54 Task { 55 do { 56 _ = try await seoService.analyzeWebsite(url: urlToAnalyze) 57 showingAnalysisResults = true 58 } catch { 59 await MainActor.run { 60 errorMessage = error.localizedDescription 61 errorAlert = true 62 } 63 } 64 } 65 } 66 } 67 68 struct URLInputSection: View { 69 @Binding var urlToAnalyze: String 70 let onAnalyze: () -> Void 71 72 var body: some View { 73 VStack(alignment: .leading, spacing: 16) { 74 Text("Website Analysis") 75 .font(.title2) 76 .fontWeight(.semibold) 77 78 Text("Enter a URL to analyze its SEO performance, keyword usage, and technical implementation.") 79 .font(.subheadline) 80 .foregroundColor(.secondary) 81 82 HStack(spacing: 12) { 83 TextField("Enter website URL (e.g., https://example.com)", text: $urlToAnalyze) 84 .textFieldStyle(RoundedBorderTextFieldStyle()) 85 .onSubmit { 86 onAnalyze() 87 } 88 89 Button(action: onAnalyze) { 90 HStack { 91 Image(systemName: "magnifyingglass") 92 Text("Analyze") 93 } 94 } 95 .buttonStyle(.borderedProminent) 96 .disabled(urlToAnalyze.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) 97 } 98 } 99 .padding(20) 100 .background(Color(NSColor.controlBackgroundColor)) 101 .cornerRadius(12) 102 } 103 } 104 105 struct AnalysisProgressView: View { 106 @EnvironmentObject var seoService: SEOAnalysisService 107 108 var body: some View { 109 VStack(spacing: 20) { 110 Text("Analyzing Website...") 111 .font(.title3) 112 .fontWeight(.semibold) 113 114 ProgressView(value: seoService.progress, total: 1.0) 115 .progressViewStyle(LinearProgressViewStyle()) 116 .frame(width: 300) 117 118 Text("\(Int(seoService.progress * 100))% Complete") 119 .font(.caption) 120 .foregroundColor(.secondary) 121 122 VStack(alignment: .leading, spacing: 8) { 123 ProgressStep( 124 title: "Fetching HTML Content", 125 isComplete: seoService.progress > 0.1, 126 isActive: seoService.progress <= 0.3 127 ) 128 129 ProgressStep( 130 title: "Analyzing Page Structure", 131 isComplete: seoService.progress > 0.3, 132 isActive: seoService.progress > 0.1 && seoService.progress <= 0.5 133 ) 134 135 ProgressStep( 136 title: "Extracting Keywords", 137 isComplete: seoService.progress > 0.5, 138 isActive: seoService.progress > 0.3 && seoService.progress <= 0.7 139 ) 140 141 ProgressStep( 142 title: "Technical SEO Audit", 143 isComplete: seoService.progress > 0.7, 144 isActive: seoService.progress > 0.5 && seoService.progress <= 0.9 145 ) 146 147 ProgressStep( 148 title: "PageSpeed Insights", 149 isComplete: seoService.progress > 0.9, 150 isActive: seoService.progress > 0.7 151 ) 152 } 153 } 154 .padding(40) 155 .background(Color(NSColor.controlBackgroundColor)) 156 .cornerRadius(12) 157 } 158 } 159 160 struct ProgressStep: View { 161 let title: String 162 let isComplete: Bool 163 let isActive: Bool 164 165 var body: some View { 166 HStack(spacing: 12) { 167 Image(systemName: isComplete ? "checkmark.circle.fill" : (isActive ? "circle.dotted" : "circle")) 168 .foregroundColor(isComplete ? .green : (isActive ? .blue : .secondary)) 169 .font(.title3) 170 171 Text(title) 172 .font(.subheadline) 173 .foregroundColor(isActive || isComplete ? .primary : .secondary) 174 } 175 } 176 } 177 178 struct AnalysisResultsView: View { 179 let analysis: SEOAnalysis 180 181 var body: some View { 182 VStack(spacing: 0) { 183 // Results Header 184 AnalysisResultsHeader(analysis: analysis) 185 186 // Native macOS TabView 187 TabView { 188 OverviewTabContent(analysis: analysis) 189 .tabItem { 190 Label("Overview", systemImage: "chart.bar.fill") 191 } 192 193 KeywordsTabContent(analysis: analysis) 194 .tabItem { 195 Label("Keywords", systemImage: "key.fill") 196 } 197 198 TechnicalTabContent(analysis: analysis) 199 .tabItem { 200 Label("Technical", systemImage: "gearshape.fill") 201 } 202 203 IssuesTabContent(analysis: analysis) 204 .tabItem { 205 Label("Issues", systemImage: "exclamationmark.triangle.fill") 206 } 207 } 208 .background(Color(NSColor.controlBackgroundColor)) 209 } 210 .background(Color(NSColor.controlBackgroundColor)) 211 .cornerRadius(12) 212 } 213 } 214 215 216 217 struct AnalysisResultsHeader: View { 218 let analysis: SEOAnalysis 219 220 var body: some View { 221 HStack(spacing: 20) { 222 VStack(alignment: .leading, spacing: 8) { 223 Text(analysis.url) 224 .font(.title2) 225 .fontWeight(.semibold) 226 .lineLimit(1) 227 228 Text("Analyzed \(analysis.timestamp, style: .relative)") 229 .font(.caption) 230 .foregroundColor(.secondary) 231 } 232 233 Spacer() 234 235 // Overall Score Circle 236 ZStack { 237 Circle() 238 .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 8) 239 .frame(width: 80, height: 80) 240 241 Circle() 242 .trim(from: 0, to: analysis.overallScore / 100) 243 .stroke(scoreColor, style: StrokeStyle(lineWidth: 8, lineCap: .round)) 244 .frame(width: 80, height: 80) 245 .rotationEffect(.degrees(-90)) 246 247 VStack(spacing: 2) { 248 Text("\(Int(analysis.overallScore))") 249 .font(.title) 250 .fontWeight(.bold) 251 252 Text("Score") 253 .font(.caption) 254 .foregroundColor(.secondary) 255 } 256 } 257 } 258 .padding(20) 259 .background(Color(NSColor.textBackgroundColor)) 260 } 261 262 private var scoreColor: Color { 263 switch analysis.overallScore { 264 case 80...: 265 return .green 266 case 60..<80: 267 return .orange 268 default: 269 return .red 270 } 271 } 272 } 273 274 275 276 struct OverviewTabContent: View { 277 let analysis: SEOAnalysis 278 279 var body: some View { 280 ScrollView { 281 VStack(spacing: 20) { 282 // Score Breakdown 283 ScoreBreakdownView(analysis: analysis) 284 285 // Key Metrics 286 KeyMetricsView(analysis: analysis) 287 288 // PageSpeed Insights Summary 289 if let pageSpeedInsights = analysis.pageSpeedInsights { 290 PageSpeedInsightsSummaryView(insights: pageSpeedInsights) 291 } 292 } 293 .padding(20) 294 } 295 } 296 } 297 298 struct ScoreBreakdownView: View { 299 let analysis: SEOAnalysis 300 301 var body: some View { 302 VStack(alignment: .leading, spacing: 16) { 303 Text("Score Breakdown") 304 .font(.headline) 305 .fontWeight(.semibold) 306 307 LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 16) { 308 ScoreCard(title: "Title Tag", score: analysis.metrics.titleTag.score, color: .blue) 309 ScoreCard(title: "Meta Description", score: analysis.metrics.metaDescription.score, color: .green) 310 ScoreCard(title: "Headings", score: analysis.metrics.headings.score, color: .orange) 311 ScoreCard(title: "Images", score: analysis.metrics.images.score, color: .purple) 312 ScoreCard(title: "Keywords", score: analysis.metrics.keywords.score, color: .red) 313 ScoreCard(title: "Technical SEO", score: analysis.metrics.technicalSEO.score, color: .teal) 314 } 315 } 316 } 317 } 318 319 struct ScoreCard: View { 320 let title: String 321 let score: Double 322 let color: Color 323 324 var body: some View { 325 VStack(spacing: 8) { 326 Text(title) 327 .font(.caption) 328 .foregroundColor(.secondary) 329 .multilineTextAlignment(.center) 330 331 ZStack { 332 Circle() 333 .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 4) 334 .frame(width: 40, height: 40) 335 336 Circle() 337 .trim(from: 0, to: score / 100) 338 .stroke(color, style: StrokeStyle(lineWidth: 4, lineCap: .round)) 339 .frame(width: 40, height: 40) 340 .rotationEffect(.degrees(-90)) 341 342 Text("\(Int(score))") 343 .font(.caption) 344 .fontWeight(.semibold) 345 } 346 } 347 .padding(12) 348 .background(Color(NSColor.textBackgroundColor)) 349 .cornerRadius(8) 350 } 351 } 352 353 struct KeyMetricsView: View { 354 let analysis: SEOAnalysis 355 356 var body: some View { 357 VStack(alignment: .leading, spacing: 16) { 358 Text("Key Metrics") 359 .font(.headline) 360 .fontWeight(.semibold) 361 362 VStack(spacing: 12) { 363 MetricRow( 364 label: "Title Length", 365 value: "\(analysis.metrics.titleTag.length) characters", 366 status: analysis.metrics.titleTag.isOptimal ? .good : .warning 367 ) 368 369 MetricRow( 370 label: "Meta Description Length", 371 value: "\(analysis.metrics.metaDescription.length) characters", 372 status: analysis.metrics.metaDescription.isOptimal ? .good : .warning 373 ) 374 375 MetricRow( 376 label: "H1 Tags", 377 value: "\(analysis.metrics.headings.h1Count)", 378 status: analysis.metrics.headings.h1Count == 1 ? .good : .warning 379 ) 380 381 MetricRow( 382 label: "Internal Links", 383 value: "\(analysis.metrics.internalLinks.count)", 384 status: analysis.metrics.internalLinks.count > 0 ? .good : .info 385 ) 386 387 MetricRow( 388 label: "External Links", 389 value: "\(analysis.metrics.externalLinks.count)", 390 status: analysis.metrics.externalLinks.count > 0 ? .good : .info 391 ) 392 393 MetricRow( 394 label: "Images without Alt Text", 395 value: "\(analysis.metrics.images.imagesWithoutAlt)", 396 status: analysis.metrics.images.imagesWithoutAlt == 0 ? .good : .error 397 ) 398 } 399 } 400 } 401 } 402 403 enum MetricStatus { 404 case good 405 case warning 406 case error 407 case info 408 409 var color: Color { 410 switch self { 411 case .good: return .green 412 case .warning: return .orange 413 case .error: return .red 414 case .info: return .blue 415 } 416 } 417 418 var icon: String { 419 switch self { 420 case .good: return "checkmark.circle.fill" 421 case .warning: return "exclamationmark.triangle.fill" 422 case .error: return "xmark.circle.fill" 423 case .info: return "info.circle.fill" 424 } 425 } 426 } 427 428 struct MetricRow: View { 429 let label: String 430 let value: String 431 let status: MetricStatus 432 433 var body: some View { 434 HStack { 435 Text(label) 436 .font(.subheadline) 437 438 Spacer() 439 440 HStack(spacing: 8) { 441 Text(value) 442 .font(.subheadline) 443 .fontWeight(.medium) 444 445 Image(systemName: status.icon) 446 .foregroundColor(status.color) 447 } 448 } 449 .padding(.vertical, 4) 450 } 451 } 452 453 struct KeywordsTabContent: View { 454 let analysis: SEOAnalysis 455 456 var body: some View { 457 ScrollView { 458 VStack(spacing: 20) { 459 // Keyword Overview 460 KeywordOverviewView(keywords: analysis.metrics.keywords) 461 462 // Keyword Distribution 463 KeywordDistributionView(distribution: analysis.metrics.keywords.keywordDistribution) 464 465 // TF-IDF Scores 466 TFIDFScoresView(scores: analysis.metrics.keywords.tfIdfScores) 467 } 468 .padding(20) 469 } 470 } 471 } 472 473 struct KeywordOverviewView: View { 474 let keywords: KeywordAnalysis 475 476 var body: some View { 477 VStack(alignment: .leading, spacing: 16) { 478 Text("Keyword Overview") 479 .font(.headline) 480 .fontWeight(.semibold) 481 482 HStack(spacing: 20) { 483 KeywordCategoryCard( 484 title: "Primary Keywords", 485 count: keywords.primaryKeywords.count, 486 color: .red 487 ) 488 489 KeywordCategoryCard( 490 title: "Secondary Keywords", 491 count: keywords.secondaryKeywords.count, 492 color: .orange 493 ) 494 495 KeywordCategoryCard( 496 title: "Long-tail Keywords", 497 count: keywords.longtailKeywords.count, 498 color: .blue 499 ) 500 } 501 502 VStack(alignment: .leading, spacing: 8) { 503 Text("Keyword Density: \(String(format: "%.2f%%", keywords.keywordDensity * 100))") 504 .font(.subheadline) 505 506 ProgressView(value: keywords.keywordDensity, total: 0.05) // 5% max 507 .progressViewStyle(LinearProgressViewStyle(tint: keywords.keywordDensity > 0.03 ? .red : .green)) 508 } 509 } 510 } 511 } 512 513 struct KeywordCategoryCard: View { 514 let title: String 515 let count: Int 516 let color: Color 517 518 var body: some View { 519 VStack(spacing: 8) { 520 Text("\(count)") 521 .font(.title) 522 .fontWeight(.bold) 523 .foregroundColor(color) 524 525 Text(title) 526 .font(.caption) 527 .foregroundColor(.secondary) 528 .multilineTextAlignment(.center) 529 } 530 .frame(maxWidth: .infinity) 531 .padding(16) 532 .background(Color(NSColor.textBackgroundColor)) 533 .cornerRadius(8) 534 } 535 } 536 537 struct KeywordDistributionView: View { 538 let distribution: [KeywordDistribution] 539 540 var body: some View { 541 VStack(alignment: .leading, spacing: 16) { 542 Text("Keyword Distribution") 543 .font(.headline) 544 .fontWeight(.semibold) 545 546 LazyVStack(spacing: 8) { 547 ForEach(distribution.prefix(10), id: \.id) { item in 548 KeywordDistributionRow(distribution: item) 549 } 550 } 551 } 552 } 553 } 554 555 struct KeywordDistributionRow: View { 556 let distribution: KeywordDistribution 557 558 var body: some View { 559 VStack(alignment: .leading, spacing: 8) { 560 Text(distribution.keyword) 561 .font(.subheadline) 562 .fontWeight(.medium) 563 564 HStack(spacing: 12) { 565 DistributionIndicator(label: "Title", isPresent: distribution.titlePresence) 566 DistributionIndicator(label: "H1", isPresent: distribution.h1Presence) 567 DistributionIndicator(label: "H2", isPresent: distribution.h2Presence) 568 DistributionIndicator(label: "Meta", isPresent: distribution.metaDescriptionPresence) 569 DistributionIndicator(label: "Content", isPresent: distribution.contentPresence) 570 DistributionIndicator(label: "URL", isPresent: distribution.urlPresence) 571 } 572 } 573 .padding(12) 574 .background(Color(NSColor.textBackgroundColor)) 575 .cornerRadius(8) 576 } 577 } 578 579 struct DistributionIndicator: View { 580 let label: String 581 let isPresent: Bool 582 583 var body: some View { 584 VStack(spacing: 4) { 585 Circle() 586 .fill(isPresent ? Color.green : Color.secondary.opacity(0.3)) 587 .frame(width: 8, height: 8) 588 589 Text(label) 590 .font(.caption2) 591 .foregroundColor(.secondary) 592 } 593 } 594 } 595 596 struct TFIDFScoresView: View { 597 let scores: [TFIDFScore] 598 599 var body: some View { 600 VStack(alignment: .leading, spacing: 16) { 601 Text("TF-IDF Scores") 602 .font(.headline) 603 .fontWeight(.semibold) 604 605 Text("Terms ranked by TF-IDF relevance score") 606 .font(.caption) 607 .foregroundColor(.secondary) 608 609 LazyVStack(spacing: 8) { 610 ForEach(scores.prefix(10), id: \.id) { score in 611 TFIDFScoreRow(score: score) 612 } 613 } 614 } 615 } 616 } 617 618 struct TFIDFScoreRow: View { 619 let score: TFIDFScore 620 621 var body: some View { 622 HStack { 623 VStack(alignment: .leading, spacing: 2) { 624 Text(score.term) 625 .font(.subheadline) 626 .fontWeight(.medium) 627 628 Text("TF: \(String(format: "%.3f", score.termFrequency)) | IDF: \(String(format: "%.3f", score.inverseDocumentFrequency))") 629 .font(.caption) 630 .foregroundColor(.secondary) 631 } 632 633 Spacer() 634 635 Text(String(format: "%.3f", score.tfIdf)) 636 .font(.subheadline) 637 .fontWeight(.semibold) 638 .foregroundColor(.accentColor) 639 } 640 .padding(12) 641 .background(Color(NSColor.textBackgroundColor)) 642 .cornerRadius(8) 643 } 644 } 645 646 struct TechnicalTabContent: View { 647 let analysis: SEOAnalysis 648 649 var body: some View { 650 ScrollView { 651 VStack(spacing: 20) { 652 TechnicalSEOChecklist(technical: analysis.metrics.technicalSEO) 653 SemanticStructureView(semantic: analysis.metrics.semanticStructure) 654 // Links Analysis Summary 655 LinksAnalysisSummaryView( 656 internalLinks: analysis.metrics.internalLinks.links.map(\.url), 657 externalLinks: analysis.metrics.externalLinks.links.map(\.url) 658 ) 659 } 660 .padding(20) 661 } 662 } 663 } 664 665 struct TechnicalSEOChecklist: View { 666 let technical: TechnicalSEOAnalysis 667 668 var body: some View { 669 VStack(alignment: .leading, spacing: 16) { 670 Text("Technical SEO Checklist") 671 .font(.headline) 672 .fontWeight(.semibold) 673 674 VStack(spacing: 8) { 675 ChecklistItem(label: "Sitemap.xml", isPresent: technical.hasSitemap) 676 ChecklistItem(label: "Robots.txt", isPresent: technical.hasRobotsTxt) 677 ChecklistItem(label: "Canonical Tags", isPresent: technical.hasCanonicalTags) 678 ChecklistItem(label: "Open Graph Tags", isPresent: technical.hasOpenGraphTags) 679 ChecklistItem(label: "Twitter Card Tags", isPresent: technical.hasTwitterCardTags) 680 ChecklistItem(label: "Structured Data", isPresent: technical.hasStructuredData) 681 } 682 } 683 } 684 } 685 686 struct ChecklistItem: View { 687 let label: String 688 let isPresent: Bool 689 690 var body: some View { 691 HStack { 692 Image(systemName: isPresent ? "checkmark.circle.fill" : "xmark.circle.fill") 693 .foregroundColor(isPresent ? .green : .red) 694 695 Text(label) 696 .font(.subheadline) 697 698 Spacer() 699 } 700 .padding(.vertical, 4) 701 } 702 } 703 704 struct SemanticStructureView: View { 705 let semantic: SemanticStructureAnalysis 706 707 var body: some View { 708 VStack(alignment: .leading, spacing: 16) { 709 Text("Semantic Structure") 710 .font(.headline) 711 .fontWeight(.semibold) 712 713 LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 12) { 714 ForEach(semantic.semanticElements, id: \.id) { element in 715 SemanticElementCard(element: element) 716 } 717 } 718 } 719 } 720 } 721 722 struct SemanticElementCard: View { 723 let element: SemanticElement 724 725 var body: some View { 726 VStack(spacing: 8) { 727 Text("<\(element.tag)>") 728 .font(.caption) 729 .fontWeight(.medium) 730 .foregroundColor(.accentColor) 731 732 Text("\(element.count)") 733 .font(.title2) 734 .fontWeight(.bold) 735 736 Circle() 737 .fill(element.isOptimal ? Color.green : Color.orange) 738 .frame(width: 8, height: 8) 739 } 740 .frame(maxWidth: .infinity) 741 .padding(12) 742 .background(Color(NSColor.textBackgroundColor)) 743 .cornerRadius(8) 744 } 745 } 746 747 struct IssuesTabContent: View { 748 let analysis: SEOAnalysis 749 750 var body: some View { 751 ScrollView { 752 VStack(spacing: 20) { 753 IssuesListView(issues: analysis.issues) 754 SuggestionsListView(suggestions: analysis.suggestions) 755 } 756 .padding(20) 757 } 758 } 759 } 760 761 struct IssuesListView: View { 762 let issues: [SEOIssue] 763 764 var body: some View { 765 VStack(alignment: .leading, spacing: 16) { 766 Text("Issues Found (\(issues.count))") 767 .font(.headline) 768 .fontWeight(.semibold) 769 770 if issues.isEmpty { 771 URLAnalysisEmptyStateView( 772 icon: "checkmark.circle", 773 title: "No issues found", 774 subtitle: "Great! This page doesn't have any SEO issues" 775 ) 776 } else { 777 LazyVStack(spacing: 12) { 778 ForEach(issues, id: \.id) { issue in 779 IssueCard(issue: issue) 780 } 781 } 782 } 783 } 784 } 785 } 786 787 struct IssueCard: View { 788 let issue: SEOIssue 789 790 var body: some View { 791 VStack(alignment: .leading, spacing: 12) { 792 HStack { 793 Image(systemName: "exclamationmark.triangle.fill") 794 .foregroundColor(issue.severity.color) 795 796 VStack(alignment: .leading, spacing: 4) { 797 Text(issue.title) 798 .font(.subheadline) 799 .fontWeight(.semibold) 800 801 Text(issue.severity.rawValue.capitalized) 802 .font(.caption) 803 .padding(.horizontal, 8) 804 .padding(.vertical, 2) 805 .background(issue.severity.color.opacity(0.2)) 806 .foregroundColor(issue.severity.color) 807 .cornerRadius(4) 808 } 809 810 Spacer() 811 } 812 813 Text(issue.description) 814 .font(.subheadline) 815 .foregroundColor(.secondary) 816 817 if let element = issue.element { 818 Text("Element: \(element)") 819 .font(.system(.caption, design: .monospaced)) 820 .padding(8) 821 .background(Color.secondary.opacity(0.1)) 822 .cornerRadius(4) 823 } 824 825 VStack(alignment: .leading, spacing: 4) { 826 Text("Recommendation:") 827 .font(.caption) 828 .fontWeight(.medium) 829 830 Text(issue.recommendation) 831 .font(.caption) 832 .foregroundColor(.secondary) 833 } 834 } 835 .padding(16) 836 .background(Color(NSColor.textBackgroundColor)) 837 .cornerRadius(8) 838 .overlay( 839 RoundedRectangle(cornerRadius: 8) 840 .strokeBorder(issue.severity.color.opacity(0.3), lineWidth: 1) 841 ) 842 } 843 } 844 845 struct SuggestionsListView: View { 846 let suggestions: [SEOSuggestion] 847 848 var body: some View { 849 VStack(alignment: .leading, spacing: 16) { 850 Text("Suggestions (\(suggestions.count))") 851 .font(.headline) 852 .fontWeight(.semibold) 853 854 if suggestions.isEmpty { 855 URLAnalysisEmptyStateView( 856 icon: "lightbulb", 857 title: "No suggestions available", 858 subtitle: "This page is well optimized" 859 ) 860 } else { 861 LazyVStack(spacing: 12) { 862 ForEach(suggestions, id: \.id) { suggestion in 863 SuggestionCard(suggestion: suggestion) 864 } 865 } 866 } 867 } 868 } 869 } 870 871 struct SuggestionCard: View { 872 let suggestion: SEOSuggestion 873 874 var body: some View { 875 VStack(alignment: .leading, spacing: 12) { 876 HStack { 877 Image(systemName: "lightbulb.fill") 878 .foregroundColor(.yellow) 879 880 VStack(alignment: .leading, spacing: 4) { 881 Text(suggestion.title) 882 .font(.subheadline) 883 .fontWeight(.semibold) 884 885 Text(suggestion.priority.rawValue.capitalized) 886 .font(.caption) 887 .padding(.horizontal, 8) 888 .padding(.vertical, 2) 889 .background(suggestion.priority.color.opacity(0.2)) 890 .foregroundColor(suggestion.priority.color) 891 .cornerRadius(4) 892 } 893 894 Spacer() 895 } 896 897 Text(suggestion.description) 898 .font(.subheadline) 899 .foregroundColor(.secondary) 900 901 HStack(spacing: 16) { 902 VStack(alignment: .leading, spacing: 4) { 903 Text("Impact:") 904 .font(.caption) 905 .fontWeight(.medium) 906 907 Text(suggestion.estimatedImpact) 908 .font(.caption) 909 .foregroundColor(.secondary) 910 } 911 912 VStack(alignment: .leading, spacing: 4) { 913 Text("Difficulty:") 914 .font(.caption) 915 .fontWeight(.medium) 916 917 Text(suggestion.implementationDifficulty) 918 .font(.caption) 919 .foregroundColor(.secondary) 920 } 921 } 922 } 923 .padding(16) 924 .background(Color(NSColor.textBackgroundColor)) 925 .cornerRadius(8) 926 .overlay( 927 RoundedRectangle(cornerRadius: 8) 928 .strokeBorder(Color.accentColor.opacity(0.3), lineWidth: 1) 929 ) 930 } 931 } 932 933 extension SEOSuggestionPriority { 934 var color: Color { 935 switch self { 936 case .critical: return .red 937 case .high: return .orange 938 case .medium: return .yellow 939 case .low: return .blue 940 } 941 } 942 } 943 944 struct EmptyAnalysisView: View { 945 var body: some View { 946 VStack(spacing: 24) { 947 Image(systemName: "magnifyingglass.circle") 948 .font(.system(size: 64)) 949 .foregroundColor(.secondary) 950 951 VStack(spacing: 8) { 952 Text("Ready to Analyze") 953 .font(.title2) 954 .fontWeight(.semibold) 955 956 Text("Enter a URL above to start analyzing its SEO performance") 957 .font(.subheadline) 958 .foregroundColor(.secondary) 959 .multilineTextAlignment(.center) 960 } 961 } 962 .frame(maxWidth: .infinity) 963 .frame(height: 300) 964 .background(Color(NSColor.controlBackgroundColor)) 965 .cornerRadius(12) 966 } 967 } 968 969 // MARK: - Links Analysis Summary View 970 971 struct LinksAnalysisSummaryView: View { 972 let internalLinks: [String] 973 let externalLinks: [String] 974 975 var body: some View { 976 VStack(alignment: .leading, spacing: 16) { 977 Text("Links Analysis") 978 .font(.headline) 979 .fontWeight(.semibold) 980 981 HStack(spacing: 20) { 982 internalLinksSection 983 externalLinksSection 984 } 985 } 986 .padding(20) 987 .background(Color(NSColor.controlBackgroundColor)) 988 .cornerRadius(12) 989 } 990 991 private var internalLinksSection: some View { 992 VStack(alignment: .leading, spacing: 8) { 993 HStack { 994 Image(systemName: "link") 995 .foregroundColor(.blue) 996 Text("Internal Links") 997 .font(.subheadline) 998 .fontWeight(.medium) 999 } 1000 1001 Text("\(internalLinks.count) links found") 1002 .font(.caption) 1003 .foregroundColor(.secondary) 1004 1005 if !internalLinks.isEmpty { 1006 ScrollView { 1007 VStack(alignment: .leading, spacing: 4) { 1008 ForEach(Array(internalLinks.prefix(5)), id: \.self) { link in 1009 Text(link) 1010 .font(.caption) 1011 .foregroundColor(.secondary) 1012 .lineLimit(1) 1013 } 1014 1015 if internalLinks.count > 5 { 1016 Text("... and \(internalLinks.count - 5) more") 1017 .font(.caption) 1018 .foregroundColor(.secondary) 1019 .italic() 1020 } 1021 } 1022 } 1023 .frame(maxHeight: 100) 1024 } 1025 } 1026 .frame(maxWidth: .infinity, alignment: .leading) 1027 .padding(12) 1028 .background(Color(NSColor.textBackgroundColor)) 1029 .cornerRadius(8) 1030 } 1031 1032 private var externalLinksSection: some View { 1033 VStack(alignment: .leading, spacing: 8) { 1034 HStack { 1035 Image(systemName: "link.badge.plus") 1036 .foregroundColor(.green) 1037 Text("External Links") 1038 .font(.subheadline) 1039 .fontWeight(.medium) 1040 } 1041 1042 Text("\(externalLinks.count) links found") 1043 .font(.caption) 1044 .foregroundColor(.secondary) 1045 1046 if !externalLinks.isEmpty { 1047 ScrollView { 1048 VStack(alignment: .leading, spacing: 4) { 1049 ForEach(Array(externalLinks.prefix(5)), id: \.self) { link in 1050 Text(link) 1051 .font(.caption) 1052 .foregroundColor(.secondary) 1053 .lineLimit(1) 1054 } 1055 1056 if externalLinks.count > 5 { 1057 Text("... and \(externalLinks.count - 5) more") 1058 .font(.caption) 1059 .foregroundColor(.secondary) 1060 .italic() 1061 } 1062 } 1063 } 1064 .frame(maxHeight: 100) 1065 } 1066 } 1067 .frame(maxWidth: .infinity, alignment: .leading) 1068 .padding(12) 1069 .background(Color(NSColor.textBackgroundColor)) 1070 .cornerRadius(8) 1071 } 1072 } 1073 1074 // MARK: - PageSpeed Insights Summary View 1075 1076 struct PageSpeedInsightsSummaryView: View { 1077 let insights: PageSpeedInsights 1078 1079 var body: some View { 1080 VStack(alignment: .leading, spacing: 16) { 1081 Text("PageSpeed Insights") 1082 .font(.headline) 1083 .fontWeight(.semibold) 1084 1085 HStack(spacing: 20) { 1086 // Performance Score 1087 VStack(spacing: 8) { 1088 Text("\(Int(insights.performanceScore))") 1089 .font(.largeTitle) 1090 .fontWeight(.bold) 1091 .foregroundColor(scoreColor(insights.performanceScore)) 1092 1093 Text("Performance Score") 1094 .font(.caption) 1095 .foregroundColor(.secondary) 1096 } 1097 .frame(maxWidth: .infinity) 1098 .padding(16) 1099 .background(Color(NSColor.textBackgroundColor)) 1100 .cornerRadius(8) 1101 1102 // Core Web Vitals 1103 VStack(alignment: .leading, spacing: 12) { 1104 Text("Core Web Vitals") 1105 .font(.subheadline) 1106 .fontWeight(.medium) 1107 1108 VStack(alignment: .leading, spacing: 8) { 1109 HStack { 1110 Text("LCP:") 1111 .font(.caption) 1112 .fontWeight(.medium) 1113 Spacer() 1114 Text("\(String(format: "%.1f", insights.largestContentfulPaint ?? 0.0))s") 1115 .font(.caption) 1116 .foregroundColor(.secondary) 1117 } 1118 1119 HStack { 1120 Text("FID:") 1121 .font(.caption) 1122 .fontWeight(.medium) 1123 Spacer() 1124 Text("\(String(format: "%.1f", insights.firstInputDelay ?? 0.0))ms") 1125 .font(.caption) 1126 .foregroundColor(.secondary) 1127 } 1128 1129 HStack { 1130 Text("CLS:") 1131 .font(.caption) 1132 .fontWeight(.medium) 1133 Spacer() 1134 Text("\(String(format: "%.3f", insights.cumulativeLayoutShift ?? 0.0))") 1135 .font(.caption) 1136 .foregroundColor(.secondary) 1137 } 1138 } 1139 } 1140 .frame(maxWidth: .infinity, alignment: .leading) 1141 .padding(16) 1142 .background(Color(NSColor.textBackgroundColor)) 1143 .cornerRadius(8) 1144 } 1145 1146 // Opportunities (if available) 1147 if !insights.opportunities.isEmpty { 1148 VStack(alignment: .leading, spacing: 8) { 1149 Text("Key Opportunities") 1150 .font(.subheadline) 1151 .fontWeight(.medium) 1152 1153 ForEach(Array(insights.opportunities.prefix(3)), id: \.self) { opportunity in 1154 HStack { 1155 Image(systemName: "lightbulb.fill") 1156 .foregroundColor(.orange) 1157 .font(.caption) 1158 1159 Text(opportunity) 1160 .font(.caption) 1161 .foregroundColor(.secondary) 1162 1163 Spacer() 1164 } 1165 } 1166 1167 if insights.opportunities.count > 3 { 1168 Text("... and \(insights.opportunities.count - 3) more") 1169 .font(.caption2) 1170 .foregroundColor(.secondary) 1171 .italic() 1172 } 1173 } 1174 } 1175 } 1176 .padding(20) 1177 .background(Color(NSColor.controlBackgroundColor)) 1178 .cornerRadius(12) 1179 } 1180 1181 private func scoreColor(_ score: Double) -> Color { 1182 switch score { 1183 case 90...: return .green 1184 case 50..<90: return .orange 1185 default: return .red 1186 } 1187 } 1188 } 1189 1190 struct URLAnalysisEmptyStateView: View { 1191 let icon: String 1192 let title: String 1193 let subtitle: String 1194 1195 var body: some View { 1196 VStack(spacing: 16) { 1197 Image(systemName: icon) 1198 .font(.system(size: 48)) 1199 .foregroundColor(.secondary) 1200 1201 Text(title) 1202 .font(.title2) 1203 .fontWeight(.semibold) 1204 1205 Text(subtitle) 1206 .font(.subheadline) 1207 .foregroundColor(.secondary) 1208 .multilineTextAlignment(.center) 1209 } 1210 .frame(maxWidth: .infinity, maxHeight: .infinity) 1211 .padding(40) 1212 } 1213 } 1214 1215 #Preview { 1216 URLAnalysisView() 1217 .environmentObject(SEOAnalysisService()) 1218 .frame(width: 1000, height: 800) 1219 }