Obrazki w Angularze

1. Dlaczego obrazki w aplikacji?

Obrazki to podstawowy element każdej nowoczesnej aplikacji webowej:

  • Plakaty filmów w aplikacji kinowej
  • Avatary użytkowników w social media
  • Zdjęcia produktów w sklepie internetowym
  • Ikony i logo aplikacji
  • Tła i dekoracje

Gdzie mogą być przechowywane obrazki:

  • 📁 Lokalnie – w folderze projektu Angular
  • 🌐 Zewnętrzne URL – na innych serwerach
  • ☁️ Chmura – Google Drive, Cloudinary, AWS
  • 📊 Bazy danych – jako linki URL

2. Struktura folderów dla obrazków w Angular

Standardowa struktura projektu:

src/
├── app/
├── assets/          👈 TUTAJ TRZYMAMY OBRAZKI!
│   ├── images/
│   │   ├── logos/
│   │   │   ├── logo.png
│   │   │   └── favicon.ico
│   │   ├── films/
│   │   │   ├── avengers.jpg
│   │   │   ├── batman.jpg
│   │   │   └── spider-man.jpg
│   │   ├── users/
│   │   │   ├── default-avatar.png
│   │   │   └── admin-avatar.jpg
│   │   └── backgrounds/
│   │       ├── hero-bg.jpg
│   │       └── pattern.png
│   └── icons/
│       ├── star.svg
│       ├── heart.svg
│       └── delete.svg
├── index.html
└── ...

public/              👈 NIE UŻYWAMY DO OBRAZKÓW!
├── favicon.ico      (tylko podstawowe pliki)
└── ...

❓ Dlaczego NIE używamy folderu public/?

Folder public/ (jeśli istnieje) vs assets/:

Cechaassets/public/
OptymalizacjaAngular kompresuje i optymalizujeKopiowane bez zmian
Cache bustingAngular dodaje hash do nazw plikówBrak automatycznego cache busting
BundlingWłączone w proces budowaniaPomijane podczas budowania
TypeScriptSprawdzanie ścieżekBrak sprawdzania
Production readyAutomatycznie gotowe na produkcjęWymaga ręcznej konfiguracji

Przykład problemu z public/:

// ❌ PROBLEM z public/ - brak optymalizacji
<img src="public/images/film.jpg" alt="film">
// - Obrazek nie będzie skompresowany
// - Brak cache busting (problemy z aktualizacjami)
// - Angular nie sprawdzi czy plik istnieje

// ✅ ROZWIĄZANIE z assets/ - pełna optymalizacja
<img src="assets/images/film.jpg" alt="film">
// - Angular automatycznie optymalizuje
// - Dodaje hash do nazwy pliku (film.abc123.jpg)
// - Sprawdza czy plik istnieje podczas budowania

Dlaczego folder assets?

  • Angular automatycznie kopiuje zawartość assets/ do wersji produkcyjnej
  • Bezpieczny dostęp – obrazki są zawsze dostępne
  • Optymalizacja – Angular może kompresować obrazki podczas budowania
  • Cache busting – Angular dodaje hash do nazw plików w produkcji
  • Sprawdzanie błędów – Angular może wykryć brakujące obrazki podczas budowania

3. Podstawowe sposoby wyświetlania obrazków

🔧 Ważne: Różnica między src a [src]

Przed przykładami – kluczowe pojęcie:

<!-- src = STATYCZNY (zawsze ten sam) -->
<img src="assets/images/logo.jpg" alt="logo">

<!-- [src] = DYNAMICZNY (może się zmieniać) -->
<img [src]="nazwaZmiennej" alt="dynamiczny obrazek">

Analogia z życia:

  • src = adres na kopercie – zawsze ten sam
  • [src] = zmienna z adresem – może się zmieniać w zależności od sytuacji

Kiedy używać którego:

  • src – gdy obrazek nigdy się nie zmienia (logo, ikony)
  • [src] – gdy obrazek zależy od danych (zdjęcia użytkowników, plakaty filmów)

Sposób 1: Statyczne obrazki (zawsze te same)

// film-poster.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-film-poster',
  standalone: true,
  template: `
    <div class="poster-container">
      <h3>Plakat filmu</h3>
      
      <!-- Podstawowy sposób - statyczny obrazek -->
      <!-- src = zawsze ten sam obrazek -->
      <img 
        src="assets/images/films/avengers.jpg" 
        alt="Plakat Avengers"
        class="film-poster">
      
      <!-- Z opisem -->
      <p>Avengers: Endgame</p>
    </div>
  `,
  styles: [`
    .poster-container {
      text-align: center;
      padding: 20px;
    }
    .film-poster {
      width: 300px;
      height: 450px;
      object-fit: cover;
      border-radius: 8px;
      box-shadow: 0 4px 8px rgba(0,0,0,0.2);
    }
  `]
})
export class FilmPosterComponent { }

Sposób 2: Dynamiczne obrazki (zmieniają się)

// dynamic-poster.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-dynamic-poster',
  standalone: true,
  template: `
    <div class="poster-container">
      <h3>{{ currentFilm.title }}</h3>
      
      <!-- Dynamiczny obrazek - zmienia się z danymi -->
      <!-- [src] = może się zmieniać, zależy od zmiennej currentFilm -->
      <img 
        [src]="currentFilm.posterUrl" 
        [alt]="currentFilm.title + ' plakat'"
        class="film-poster"
        (error)="onImageError($event)">
      
      <!-- Przyciski do zmiany filmu -->
      <div class="controls">
        @for (film of films; track film.id) {
          <button 
            (click)="selectFilm(film)"
            [class.active]="currentFilm.id === film.id"
            class="film-btn">
            {{ film.title }}
          </button>
        }
      </div>
    </div>
  `,
  styles: [`
    .poster-container {
      text-align: center;
      padding: 20px;
    }
    .film-poster {
      width: 300px;
      height: 450px;
      object-fit: cover;
      border-radius: 8px;
      box-shadow: 0 4px 8px rgba(0,0,0,0.2);
      transition: transform 0.3s ease;
    }
    .film-poster:hover {
      transform: scale(1.05);
    }
    .controls {
      margin-top: 20px;
      display: flex;
      gap: 10px;
      justify-content: center;
      flex-wrap: wrap;
    }
    .film-btn {
      padding: 8px 16px;
      background: #f8f9fa;
      border: 1px solid #ddd;
      border-radius: 4px;
      cursor: pointer;
      transition: all 0.2s;
    }
    .film-btn:hover {
      background: #e9ecef;
    }
    .film-btn.active {
      background: #007bff;
      color: white;
    }
  `]
})
export class DynamicPosterComponent {
  films = [
    {
      id: 1,
      title: 'Avengers: Endgame',
      posterUrl: 'assets/images/films/avengers.jpg'
    },
    {
      id: 2,
      title: 'Batman',
      posterUrl: 'assets/images/films/batman.jpg'
    },
    {
      id: 3,
      title: 'Spider-Man',
      posterUrl: 'assets/images/films/spider-man.jpg'
    }
  ];
  
  currentFilm = this.films[0]; // Domyślnie pierwszy film
  
  selectFilm(film: any) {
    this.currentFilm = film;
    console.log('Wybrano film:', film.title);
  }
  
  // Obsługa błędów ładowania obrazka
  onImageError(event: any) {
    console.log('Błąd ładowania obrazka:', event);
    // Ustaw domyślny obrazek
    event.target.src = 'assets/images/films/default-poster.jpg';
  }
}

4. Obrazki z zewnętrznych źródeł (URL)

Przykład z zewnętrznymi URL

// external-images.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-external-images',
  standalone: true,
  template: `
    <div class="gallery">
      <h3>Obrazki z zewnętrznych źródeł</h3>
      
      <div class="images-grid">
        @for (image of externalImages; track image.id) {
          <div class="image-card">
            <img 
              [src]="image.url" 
              [alt]="image.title"
              class="gallery-image"
              (load)="onImageLoad(image)"
              (error)="onImageError($event, image)">
            
            <div class="image-info">
              <h4>{{ image.title }}</h4>
              <p>{{ image.description }}</p>
              @if (image.loading) {
                <span class="loading">Ładowanie...</span>
              }
            </div>
          </div>
        }
      </div>
    </div>
  `,
  styles: [`
    .gallery {
      padding: 20px;
    }
    .images-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
      gap: 20px;
      margin-top: 20px;
    }
    .image-card {
      background: white;
      border-radius: 8px;
      overflow: hidden;
      box-shadow: 0 2px 8px rgba(0,0,0,0.1);
      transition: transform 0.2s;
    }
    .image-card:hover {
      transform: translateY(-4px);
    }
    .gallery-image {
      width: 100%;
      height: 200px;
      object-fit: cover;
    }
    .image-info {
      padding: 15px;
    }
    .loading {
      color: #007bff;
      font-style: italic;
    }
  `]
})
export class ExternalImagesComponent {
  externalImages = [
    {
      id: 1,
      title: 'Krajobraz górski',
      description: 'Piękne góry w słońcu',
      url: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400',
      loading: true
    },
    {
      id: 2,
      title: 'Zachód słońca',
      description: 'Romantyczny zachód słońca',
      url: 'https://images.unsplash.com/photo-1495616811223-4d98c6e9c869?w=400',
      loading: true
    },
    {
      id: 3,
      title: 'Las jesienią',
      description: 'Kolorowy las jesienią',
      url: 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=400',
      loading: true
    }
  ];
  
  onImageLoad(image: any) {
    console.log('Obrazek załadowany:', image.title);
    image.loading = false;
  }
  
  onImageError(event: any, image: any) {
    console.log('Błąd ładowania:', image.title);
    image.loading = false;
    // Ustaw domyślny obrazek
    event.target.src = 'assets/images/default-image.jpg';
  }
}

5. Obrazki z danymi dynamicznymi

Przykład: Lista filmów z plakatami

// films-gallery.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-films-gallery',
  standalone: true,
  template: `
    <div class="films-gallery">
      <h2>Galeria filmów</h2>
      
      <!-- Pasek wyszukiwania -->
      <div class="search-bar">
        <input 
          type="text" 
          #searchInput
          (input)="searchTerm = searchInput.value; filterFilms()"
          placeholder="Wyszukaj film..."
          class="search-input">
        <span class="results-count">Znaleziono: {{ filteredFilms.length }} filmów</span>
      </div>
      
      <!-- Galeria filmów -->
      <div class="films-grid">
        @if (filteredFilms.length === 0) {
          <div class="no-results">
            <img 
              src="assets/images/icons/search-empty.svg" 
              alt="Brak wyników"
              class="empty-icon">
            <h3>Brak filmów</h3>
            <p>Nie znaleziono filmów pasujących do "{{ searchTerm }}"</p>
          </div>
        } @else {
          @for (film of filteredFilms; track film.id) {
            <div class="film-card" (click)="selectFilm(film)">
              <!-- Obrazek filmu -->
              <div class="image-container">
                <img 
                  [src]="film.posterUrl" 
                  [alt]="film.title + ' plakat'"
                  class="film-image"
                  (error)="setDefaultPoster($event)">
                
                <!-- Nakładka z oceną -->
                <div class="rating-overlay">
                  <span class="rating">{{ film.rating }}</span>
                  <img src="assets/images/icons/star.svg" alt="gwiazdka" class="star-icon">
                </div>
                
                <!-- Znacznik "Nowość" -->
                @if (film.isNew) {
                  <div class="new-badge">
                    NOWOŚĆ!
                  </div>
                }
              </div>
              
              <!-- Informacje o filmie -->
              <div class="film-info">
                <h3>{{ film.title }}</h3>
                <p class="year">{{ film.year }}</p>
                <p class="genre">{{ film.genre }}</p>
              </div>
            </div>
          }
        }
      </div>
      
      <!-- Szczegóły wybranego filmu -->
      @if (selectedFilm) {
        <div class="film-details">
          <button (click)="closeDetails()" class="close-btn">×</button>
          <div class="details-content">
            <img 
              [src]="selectedFilm.posterUrl" 
              [alt]="selectedFilm.title"
              class="details-poster">
            <div class="details-info">
              <h2>{{ selectedFilm.title }}</h2>
              <p><strong>Rok:</strong> {{ selectedFilm.year }}</p>
              <p><strong>Gatunek:</strong> {{ selectedFilm.genre }}</p>
              <p><strong>Ocena:</strong> {{ selectedFilm.rating }}/10</p>
              <p><strong>Opis:</strong> {{ selectedFilm.description }}</p>
            </div>
          </div>
        </div>
      }
    </div>
  `,
  styles: [`
    .films-gallery {
      padding: 20px;
      max-width: 1200px;
      margin: 0 auto;
    }
    
    .search-bar {
      display: flex;
      align-items: center;
      gap: 20px;
      margin-bottom: 30px;
      padding: 20px;
      background: #f8f9fa;
      border-radius: 8px;
    }
    
    .search-input {
      flex: 1;
      padding: 10px;
      border: 1px solid #ddd;
      border-radius: 4px;
      font-size: 16px;
    }
    
    .results-count {
      color: #6c757d;
      font-size: 14px;
    }
    
    .films-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
      gap: 25px;
    }
    
    .film-card {
      background: white;
      border-radius: 12px;
      overflow: hidden;
      box-shadow: 0 4px 12px rgba(0,0,0,0.1);
      transition: all 0.3s ease;
      cursor: pointer;
    }
    
    .film-card:hover {
      transform: translateY(-8px);
      box-shadow: 0 8px 25px rgba(0,0,0,0.15);
    }
    
    .image-container {
      position: relative;
      height: 300px;
      overflow: hidden;
    }
    
    .film-image {
      width: 100%;
      height: 100%;
      object-fit: cover;
      transition: transform 0.3s ease;
    }
    
    .film-card:hover .film-image {
      transform: scale(1.1);
    }
    
    .rating-overlay {
      position: absolute;
      top: 10px;
      right: 10px;
      background: rgba(0,0,0,0.8);
      color: white;
      padding: 5px 10px;
      border-radius: 20px;
      display: flex;
      align-items: center;
      gap: 5px;
    }
    
    .star-icon {
      width: 16px;
      height: 16px;
      filter: brightness(0) invert(1);
    }
    
    .new-badge {
      position: absolute;
      top: 10px;
      left: 10px;
      background: #ff4757;
      color: white;
      padding: 5px 10px;
      border-radius: 4px;
      font-size: 12px;
      font-weight: bold;
    }
    
    .film-info {
      padding: 20px;
    }
    
    .film-info h3 {
      margin: 0 0 8px 0;
      color: #333;
    }
    
    .year {
      color: #6c757d;
      margin: 4px 0;
    }
    
    .genre {
      color: #007bff;
      font-weight: 500;
      margin: 4px 0;
    }
    
    .no-results {
      grid-column: 1 / -1;
      text-align: center;
      padding: 60px 20px;
      color: #6c757d;
    }
    
    .empty-icon {
      width: 80px;
      height: 80px;
      opacity: 0.5;
      margin-bottom: 20px;
    }
    
    .film-details {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(0,0,0,0.8);
      display: flex;
      justify-content: center;
      align-items: center;
      z-index: 1000;
    }
    
    .details-content {
      background: white;
      border-radius: 12px;
      max-width: 600px;
      max-height: 80vh;
      overflow: auto;
      position: relative;
      display: flex;
      gap: 20px;
      padding: 30px;
    }
    
    .close-btn {
      position: absolute;
      top: 10px;
      right: 15px;
      background: none;
      border: none;
      font-size: 30px;
      cursor: pointer;
      color: #666;
    }
    
    .details-poster {
      width: 200px;
      height: 300px;
      object-fit: cover;
      border-radius: 8px;
    }
    
    .details-info {
      flex: 1;
    }
    
    @media (max-width: 768px) {
      .details-content {
        flex-direction: column;
        margin: 20px;
        padding: 20px;
      }
      .details-poster {
        width: 100%;
        height: 250px;
      }
    }
  `]
})
export class FilmsGalleryComponent {
  allFilms = [
    {
      id: 1,
      title: 'Avengers: Endgame',
      year: 2019,
      genre: 'Akcja',
      rating: 8.4,
      posterUrl: 'assets/images/films/avengers.jpg',
      isNew: false,
      description: 'Finałowa walka z Thanosem. Bohaterowie próbują odwrócić skutki Blip i przywrócić połowę wszechświata.'
    },
    {
      id: 2,
      title: 'Spider-Man: No Way Home',
      year: 2021,
      genre: 'Akcja',
      rating: 8.2,
      posterUrl: 'assets/images/films/spider-man.jpg',
      isNew: true,
      description: 'Peter Parker zmaga się z ujawnieniem swojej tożsamości i prosi o pomoc Doctor Strange.'
    },
    {
      id: 3,
      title: 'The Batman',
      year: 2022,
      genre: 'Kryminał',
      rating: 7.8,
      posterUrl: 'assets/images/films/batman.jpg',
      isNew: true,
      description: 'Młody Bruce Wayne ściga seryjnego mordercę Riddlera w mrocznym Gotham.'
    }
  ];
  
  filteredFilms = [...this.allFilms];
  searchTerm = '';
  selectedFilm: any = null;
  
  filterFilms() {
    if (this.searchTerm.trim() === '') {
      this.filteredFilms = [...this.allFilms];
    } else {
      this.filteredFilms = this.allFilms.filter(film =>
        film.title.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
        film.genre.toLowerCase().includes(this.searchTerm.toLowerCase())
      );
    }
  }
  
  selectFilm(film: any) {
    this.selectedFilm = film;
    console.log('Wybrano film:', film.title);
  }
  
  closeDetails() {
    this.selectedFilm = null;
  }
  
  setDefaultPoster(event: any) {
    console.log('Błąd ładowania plakatu');
    event.target.src = 'assets/images/films/default-poster.jpg';
  }
}

6. Optymalizacja obrazków

Lazy Loading – ładowanie na żądanie

// lazy-images.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-lazy-images',
  standalone: true,
  template: `
    <div class="lazy-gallery">
      <h3>Lazy Loading - obrazki ładują się gdy są potrzebne</h3>
      
      <div class="images-list">
        @for (image of images; track image.id) {
          <div class="image-item">
            <!-- loading="lazy" = ładuje tylko gdy użytkownik przewija do obrazka -->
            <img 
              [src]="image.url"
              [alt]="image.title"
              loading="lazy"
              class="lazy-image">
            <p>{{ image.title }}</p>
          </div>
        }
      </div>
    </div>
  `,
  styles: [`
    .lazy-gallery {
      padding: 20px;
    }
    .images-list {
      display: flex;
      flex-direction: column;
      gap: 30px;
    }
    .image-item {
      text-align: center;
    }
    .lazy-image {
      width: 100%;
      max-width: 600px;
      height: 400px;
      object-fit: cover;
      border-radius: 8px;
    }
  `]
})
export class LazyImagesComponent {
  images = [
    { id: 1, title: 'Obrazek 1', url: 'assets/images/gallery/image1.jpg' },
    { id: 2, title: 'Obrazek 2', url: 'assets/images/gallery/image2.jpg' },
    { id: 3, title: 'Obrazek 3', url: 'assets/images/gallery/image3.jpg' },
    // ... więcej obrazków
  ];
}

7. Najlepsze praktyki

✅ Co robić:

  1. Używaj folderu assets/ – Angular automatycznie kopiuje do produkcji
  2. Zawsze dodawaj alt – dla dostępności
  3. Obsługuj błędy – (error) z fallback obrazkiem
  4. Używaj object-fit: cover – dla spójnych rozmiarów
  5. Kompresuj obrazki – mniejsze pliki = szybsze ładowanie
  6. Używaj loading=”lazy” – dla lepszej wydajności

❌ Czego unikać:

  1. Za duże pliki – kompresuj obrazki przed dodaniem
  2. Brak alt – utrudnia dostępność
  3. Twarde kodowanie ścieżek – używaj zmiennych
  4. Brak obsługi błędów – zawsze przewiduj że obrazek może się nie załadować

Przykład dobrej praktyki:

// ✅ Dobra praktyka
export class GoodPracticeComponent {
  readonly DEFAULT_POSTER = 'assets/images/default-poster.jpg';
  readonly POSTER_PATH = 'assets/images/films/';
  
  getImageUrl(filename: string): string {
    return this.POSTER_PATH + filename;
  }
  
  onImageError(event: any) {
    event.target.src = this.DEFAULT_POSTER;
  }
}

9. Ćwiczenia do wykonania

  1. Galeria zdjęć – stwórz galerię z możliwością powiększania
  2. Avatar użytkownika – domyślny avatar + możliwość zmiany
  3. Karta produktu – główne zdjęcie + miniaturki
  4. Slider obrazków – przełączanie między obrazkami