Dataczwartek, 13 sierpnia 2026 Czas19:03:29
← Aplikacje webowe

Lekcja 24. Projekt 5: Mini portal społecznościowy — wpisy, komentarze i polubienia

Trudny / egzaminacyjny

Po co się tego uczymy?

Kolejna nazwana wprost w podstawie programowej (INF.04.7.3) funkcjonalność — portal społecznościowy. Ten projekt buduje uproszczony "feed" (strumień wpisów) z możliwością komentowania i polubień, wymagający zalogowania — dobre ćwiczenie z relacji WIELE-do-WIELU (użytkownicy i polubienia) i z pokazywania danych POWIĄZANYCH z aktualnie zalogowanym użytkownikiem.

Teoria

Wymagania projektu:

  • Feed — lista wpisów (tekst + opcjonalnie zdjęcie, upload plików z lekcji 18) od WSZYSTKICH użytkowników, posortowana od najnowszego;
  • Dodawanie własnego wpisu — WYMAGA zalogowania (ASP.NET Core Identity, lekcja 14);
  • Komentarze pod każdym wpisem — relacja jeden-do-wielu (jeden wpis, wiele komentarzy);
  • Polubienia — relacja WIELE-do-WIELU (jeden użytkownik może polubić wiele wpisów, jeden wpis może być polubiony przez wielu użytkowników) — z licznikiem i wizualnym oznaczeniem, czy AKTUALNIE zalogowany użytkownik już polubił dany wpis.

Relacja wiele-do-wielu — tabela pośrednicząca. W przeciwieństwie do relacji jeden-do-wielu z lekcji 17 (klucz obcy bezpośrednio w "słabszej" tabeli), relacja wiele-do-wielu wymaga OSOBNEJ tabeli pośredniczącej: Polubienie (WpisId, UzytkownikId) — każdy wiersz tej tabeli reprezentuje JEDNO polubienie KONKRETNEGO wpisu PRZEZ konkretnego użytkownika. Sprawdzenie "czy zalogowany użytkownik już polubił ten wpis" to zapytanie: context.Polubienia.AnyAsync(p => p.WpisId == wpisId && p.UzytkownikId == idAktualnegoUzytkownika).

Model danych:

public class Wpis { public int Id; public string Tresc; public string? SciezkaZdjecia;
                     public DateTime DataUtworzenia; public string AutorId;
                     public List<Komentarz> Komentarze; public List<Polubienie> Polubienia; }
public class Komentarz { public int Id; public string Tresc; public int WpisId;
                          public string AutorId; public DateTime DataUtworzenia; }
public class Polubienie { public int Id; public int WpisId; public string UzytkownikId; }

Pobieranie wpisów z LICZBĄ polubień i statusem "czy JA polubiłem". Zamiast dociągać CAŁĄ listę polubień dla każdego wpisu (co byłoby marnotrawstwem — potrzebujemy tylko liczby i jednego boola), backend może zwrócić OSOBNY, "spłaszczony" obiekt DTO (Data Transfer Object) zamiast surowego modelu EF Core: WpisDto { Id, Tresc, LiczbaPolubien, CzyJaPolubilem } — kontroler sam oblicza te wartości przez LINQ (.Count(), .Any()) PRZED wysłaniem odpowiedzi, zamiast przesyłać surowe dane i każąc Angularowi je przeliczać.

Schemat

Wpis (1) ──── (N) Komentarz          Wpis (N) ──── (N) Uzytkownik
"Dzisiaj super pogoda!"  "Super!" - Ala           przez tabelę pośredniczącą:
                          "Zgadzam się" - Bartek   Polubienie (WpisId, UzytkownikId)

GET /api/wpisy  (wymaga [Authorize], żeby wiedzieć KTO pyta - CzyJaPolubilem)
        │
        ▼
ASP.NET Core:
  context.Wpisy
    .Select(w => new WpisDto {
        Id = w.Id, Tresc = w.Tresc,
        LiczbaPolubien = w.Polubienia.Count(),
        CzyJaPolubilem = w.Polubienia.Any(p => p.UzytkownikId == idAktualnegoUzytkownika)
    })
        │
        ▼
Angular: FeedComponent
  @for (wpis of wpisy; track wpis.id) {
    <app-wpis-karta [wpis]="wpis" (polubiono)="przelacz(wpis.id)">
  }

Przykład z życia

Facebook, Instagram, LinkedIn — wszystkie duże portale społecznościowe opierają się na tym samym fundamencie: wpisy, komentarze, polubienia (relacja wiele-do-wielu między użytkownikami a treścią) i personalizacja widoku pod KONKRETNEGO zalogowanego użytkownika. Ten projekt buduje maleńką, ale strukturalnie prawdziwą wersję tego mechanizmu.

Controllers/WpisyController.cs

// Controllers/WpisyController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;

namespace PortalApp.Server.Controllers;

[ApiController]
[Route("api/[controller]")]
[Authorize]
public class WpisyController : ControllerBase
{
    private readonly PortalContext _context;
    public WpisyController(PortalContext context) => _context = context;

    [HttpGet]
    public async Task<ActionResult<List<WpisDto>>> GetFeed()
    {
        string mojeId = User.FindFirstValue(ClaimTypes.NameIdentifier)!;

        var wpisy = await _context.Wpisy
            .OrderByDescending(w => w.DataUtworzenia)
            .Select(w => new WpisDto
            {
                Id = w.Id,
                Tresc = w.Tresc,
                LiczbaPolubien = w.Polubienia.Count(),
                LiczbaKomentarzy = w.Komentarze.Count(),
                CzyJaPolubilem = w.Polubienia.Any(p => p.UzytkownikId == mojeId)
            })
            .ToListAsync();

        return Ok(wpisy);
    }

    [HttpPost("{id}/polub")]
    public async Task<IActionResult> PrzelaczPolubienie(int id)
    {
        string mojeId = User.FindFirstValue(ClaimTypes.NameIdentifier)!;

        var istniejace = await _context.Polubienia
            .FirstOrDefaultAsync(p => p.WpisId == id && p.UzytkownikId == mojeId);

        if (istniejace is not null)
        {
            _context.Polubienia.Remove(istniejace); // "odlubienie"
        }
        else
        {
            _context.Polubienia.Add(new Polubienie { WpisId = id, UzytkownikId = mojeId });
        }

        await _context.SaveChangesAsync();
        return Ok();
    }
}

public class WpisDto
{
    public int Id { get; set; }
    public string Tresc { get; set; } = "";
    public int LiczbaPolubien { get; set; }
    public int LiczbaKomentarzy { get; set; }
    public bool CzyJaPolubilem { get; set; }
}

wpis-karta.component.ts + feed.component.ts (fragment)

// wpis-karta.component.ts - komponent potomny pojedynczego wpisu
import { Component, Input, Output, EventEmitter } from '@angular/core';

export interface WpisDto {
  id: number;
  tresc: string;
  liczbaPolubien: number;
  liczbaKomentarzy: number;
  czyJaPolubilem: boolean;
}

@Component({
  selector: 'app-wpis-karta',
  standalone: true,
  templateUrl: './wpis-karta.component.html'
})
export class WpisKartaComponent {
  @Input() wpis!: WpisDto;
  @Output() polubiono = new EventEmitter<number>();

  kliknijPolub(): void {
    this.polubiono.emit(this.wpis.id);
  }
}

// ---------------------------------------------------------------------

// feed.component.ts - fragment obsługi przełączenia polubienia (optymistyczna aktualizacja UI)
przelaczPolubienie(wpisId: number): void {
  const wpis = this.wpisy.find(w => w.id === wpisId);
  if (!wpis) return;

  // Optymistyczna aktualizacja: zmieniamy UI NATYCHMIAST, zanim backend odpowie
  wpis.czyJaPolubilem = !wpis.czyJaPolubilem;
  wpis.liczbaPolubien += wpis.czyJaPolubilem ? 1 : -1;

  this.http.post(`https://localhost:7042/api/wpisy/${wpisId}/polub`, {})
    .subscribe({ error: () => {
      // W razie błędu - cofnij zmianę
      wpis.czyJaPolubilem = !wpis.czyJaPolubilem;
      wpis.liczbaPolubien += wpis.czyJaPolubilem ? 1 : -1;
    }});
}

Komentarz i wyjaśnienie kodu

PrzelaczPolubienie w kontrolerze C# realizuje wzorzec "toggle" (przełącznik) — jeśli polubienie JUŻ istnieje, usuwa je (odlubienie), jeśli nie istnieje, dodaje nowe; JEDNA akcja API obsługuje OBA kierunki, zamiast osobnych Polub/Odlub.

Optymistyczna aktualizacja UI (widoczna w przelaczPolubienie po stronie Angulara) to technika, gdzie interfejs aktualizuje się NATYCHMIAST (zanim backend w ogóle odpowie), zakładając, że operacja się powiedzie — dopiero w razie BŁĘDU (rzadki przypadek) cofamy zmianę. To sprawia, że aplikacja "czuje się" błyskawiczna, mimo że w tle nadal czeka na odpowiedź serwera — dokładnie tak działają przyciski "Lubię to" w prawdziwych portalach społecznościowych.

Select(w => new WpisDto { ... }) w zapytaniu EF Core — to technika mapowania encji bazy danych na osobny obiekt DTO BEZPOŚREDNIO w zapytaniu SQL (EF Core generuje odpowiednie COUNT i EXISTS w samym zapytaniu do bazy) — znacznie wydajniejsze niż pobranie WSZYSTKICH polubień do pamięci aplikacji i liczenie ich w C#.

Ćwiczenie samodzielne

Zbuduj model danych (Wpis, Komentarz, Polubienie) z migracją, kontroler zwracający feed z liczbą polubień/komentarzy, i komponent Angular wyświetlający listę wpisów z przyciskiem "Lubię to" zmieniającym stan natychmiast po kliknięciu.

Zadania do pracy własnej

  1. Dodaj wyświetlanie daty utworzenia wpisu w czytelnym formacie względnym (np. "3 minuty temu", "wczoraj") zamiast surowej daty.

  2. Dodaj możliwość dodawania komentarzy pod wpisem — formularz z jednym polem tekstowym i listą istniejących komentarzy, aktualizującą się po dodaniu nowego bez przeładowania strony.

  3. Dodaj upload zdjęcia do wpisu (lekcja 18) oraz mechanizm "obserwowanych" — tabelę pośredniczącą Obserwacja (ObserwujacyId, ObserwowanyId) pozwalającą użytkownikowi "obserwować" innych, i przefiltruj główny feed tak, żeby domyślnie pokazywał TYLKO wpisy obserwowanych użytkowników (plus własne), z możliwością przełączenia na "Wszystkie wpisy".

Typowe błędy

Pobieranie WSZYSTKICH polubień danego wpisu do Angulara tylko po to, żeby policzyć ich liczbę i sprawdzić, czy jest wśród nich aktualny użytkownik — marnotrawstwo transferu danych; te wyliczenia (Count(), Any()) powinny dziać się PO STRONIE BAZY DANYCH, nie w przeglądarce.

Brak zabezpieczenia [Authorize] na akcji polubienia — bez tego niezalogowany użytkownik (albo ktoś podszywający się pod innego, bez tokenu) mógłby polubić wpis, a system nie miałby JAK poprawnie przypisać tego polubienia do konkretnej osoby.

Brak cofnięcia optymistycznej aktualizacji UI przy błędzie żądania — jeśli backend odrzuci polubienie (np. z powodu wygasłego tokenu), a UI mimo to pokazuje polubienie jako "zapisane", użytkownik zostaje z FAŁSZYWYM wrażeniem, że jego akcja się powiodła.

Nawiązanie do egzaminu zawodowego

To realizacja "funkcjonalności portalu społecznościowego" nazwanej wprost w INF.04.7.3, ćwicząca dodatkowo relację wiele-do-wielu (rozszerzenie lekcji 17, gdzie poznałeś tylko relację jeden-do-wielu) i wzorzec DTO do efektywnego przesyłania danych. Kolejny projekt (lekcja 25) to serwis ogłoszeniowy — inna domena, podobne wyzwania z wyszukiwaniem i filtrowaniem.