Lekcja 24. Projekt 5: Mini portal społecznościowy — wpisy, komentarze i polubienia
Trudny / egzaminacyjnyPo 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
Dodaj wyświetlanie daty utworzenia wpisu w czytelnym formacie względnym (np. "3 minuty temu", "wczoraj") zamiast surowej daty.
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.
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.