diff --git a/backend/src/main/java/com/magicvs/backend/config/CardExampleSeeder.java b/backend/src/main/java/com/magicvs/backend/config/CardExampleSeeder.java new file mode 100644 index 0000000..f7148bd --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/config/CardExampleSeeder.java @@ -0,0 +1,166 @@ +package com.magicvs.backend.config; + +import com.magicvs.backend.model.Card; +import com.magicvs.backend.model.CardFace; +import com.magicvs.backend.repository.CardRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Component +public class CardExampleSeeder implements CommandLineRunner { + + private static final Logger logger = LoggerFactory.getLogger(CardExampleSeeder.class); + + private final CardRepository cardRepository; + + public CardExampleSeeder(CardRepository cardRepository) { + this.cardRepository = cardRepository; + } + + @Override + public void run(String... args) { + List examples = List.of( + new CardSeed( + "11111111-1111-1111-1111-111111111111", + "Arcane Apprentice", + "{1}{U}", + 2, + "Creature - Human Wizard", + "When Arcane Apprentice enters, draw a card.", + "[\"U\"]", + "https://placehold.co/488x680/1f2937/e5e7eb?text=Arcane+Apprentice" + ), + new CardSeed( + "22222222-2222-2222-2222-222222222222", + "Inferno Raider", + "{2}{R}", + 3, + "Creature - Goblin Berserker", + "Haste", + "[\"R\"]", + "https://placehold.co/488x680/1f2937/e5e7eb?text=Inferno+Raider" + ), + new CardSeed( + "33333333-3333-3333-3333-333333333333", + "Verdant Guardian", + "{3}{G}", + 4, + "Creature - Treefolk", + "Reach", + "[\"G\"]", + "https://placehold.co/488x680/1f2937/e5e7eb?text=Verdant+Guardian" + ), + new CardSeed( + "44444444-4444-4444-4444-444444444444", + "Radiant Pulse", + "{1}{W}", + 2, + "Instant", + "You gain 4 life and draw a card.", + "[\"W\"]", + "https://placehold.co/488x680/1f2937/e5e7eb?text=Radiant+Pulse" + ), + new CardSeed( + "55555555-5555-5555-5555-555555555555", + "Nightveil Ritual", + "{2}{B}", + 3, + "Sorcery", + "Target player discards two cards.", + "[\"B\"]", + "https://placehold.co/488x680/1f2937/e5e7eb?text=Nightveil+Ritual" + ), + new CardSeed( + "66666666-6666-6666-6666-666666666666", + "Mystic Crossroads", + "", + 0, + "Land", + "{T}: Add one mana of any color.", + "[]", + "https://placehold.co/488x680/1f2937/e5e7eb?text=Mystic+Crossroads" + ) + ); + + int inserted = 0; + for (CardSeed seed : examples) { + if (insertIfMissing(seed)) { + inserted++; + } + } + + if (inserted > 0) { + logger.info("Inserted {} sample cards for tester flows", inserted); + } + } + + private boolean insertIfMissing(CardSeed seed) { + UUID scryfallId = UUID.fromString(seed.scryfallId()); + if (cardRepository.existsByScryfallId(scryfallId)) { + return false; + } + + Card card = new Card(); + card.setScryfallId(scryfallId); + card.setName(seed.name()); + card.setLang("en"); + card.setLayout("normal"); + card.setManaCost(seed.manaCost()); + card.setCmc(BigDecimal.valueOf(seed.cmc())); + card.setTypeLine(seed.typeLine()); + card.setOracleText(seed.oracleText()); + card.setRarity("common"); + card.setReserved(false); + card.setReprint(false); + card.setDigital(false); + card.setFoil(true); + card.setNonfoil(true); + card.setPromo(false); + card.setFullArt(false); + card.setTextless(false); + card.setColorsJson(seed.colorsJson()); + card.setColorIdentityJson(seed.colorsJson()); + card.setGamesJson("[\"paper\"]"); + card.setKeywordsJson("[]"); + card.setProducedManaJson("[]"); + card.setPurchaseUrisJson("{}"); + card.setRelatedUrisJson("{}"); + card.setRawJson("{}"); + card.setSyncedAt(LocalDateTime.now()); + + CardFace face = new CardFace(); + face.setCard(card); + face.setFaceOrder(0); + face.setName(seed.name()); + face.setManaCost(seed.manaCost()); + face.setTypeLine(seed.typeLine()); + face.setOracleText(seed.oracleText()); + face.setColorsJson(seed.colorsJson()); + face.setNormalImageUri(seed.normalImageUri()); + face.setSmallImageUri(seed.normalImageUri()); + face.setLargeImageUri(seed.normalImageUri()); + + card.getFaces().add(face); + cardRepository.save(card); + return true; + } + + private record CardSeed( + String scryfallId, + String name, + String manaCost, + int cmc, + String typeLine, + String oracleText, + String colorsJson, + String normalImageUri + ) { + } +} diff --git a/backend/src/main/java/com/magicvs/backend/controller/CardControllerCardsExamples.java b/backend/src/main/java/com/magicvs/backend/controller/CardControllerCardsExamples.java new file mode 100644 index 0000000..09dd653 --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/controller/CardControllerCardsExamples.java @@ -0,0 +1,189 @@ +package com.magicvs.backend.controller; + +import com.magicvs.backend.model.Card; +import com.magicvs.backend.repository.CardRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +@RestController +@RequestMapping("/api/cards") +@CrossOrigin(origins = "http://localhost:4200") +@Transactional(readOnly = true) +public class CardControllerCardsExamples { + + private final CardRepository cardRepository; + + public CardControllerCardsExamples(CardRepository cardRepository) { + this.cardRepository = cardRepository; + } + + /** + * Busca cartas por nombre + * GET /api/cards/search?name=query&limit=20 + */ + @GetMapping("/search") + public ResponseEntity> searchCards( + @RequestParam String name, + @RequestParam(defaultValue = "20") int limit) { + + if (name == null || name.trim().isEmpty()) { + return new ResponseEntity<>(List.of(), HttpStatus.OK); + } + + Pageable pageable = PageRequest.of(0, Math.min(limit, 50)); + List results = cardRepository + .searchProjectedByName(name, pageable) + .getContent() + .stream() + .map(card -> new CardSearchResponse( + card.getId(), + card.getName(), + card.getManaCost() == null ? "" : card.getManaCost(), + card.getTypeLine() == null ? "" : card.getTypeLine(), + "https://placehold.co/488x680/111827/e5e7eb?text=MagicVS", + extractColorsFromManaCost(card.getManaCost()) + )) + .toList(); + + return new ResponseEntity<>(results, HttpStatus.OK); + } + + private static List extractColorsFromManaCost(String manaCost) { + if (manaCost == null || manaCost.isBlank()) { + return List.of(); + } + + String value = manaCost.toUpperCase(Locale.ROOT); + List colors = new ArrayList<>(); + + if (value.contains("{W}")) colors.add("W"); + if (value.contains("{U}")) colors.add("U"); + if (value.contains("{B}")) colors.add("B"); + if (value.contains("{R}")) colors.add("R"); + if (value.contains("{G}")) colors.add("G"); + + return colors; + } + + /** + * Obtiene una carta por ID + * GET /api/cards/:cardId + */ + @GetMapping("/{cardId}") + public ResponseEntity getCardById(@PathVariable Long cardId) { + var card = cardRepository.findById(cardId); + if (card.isPresent()) { + return new ResponseEntity<>(card.get(), HttpStatus.OK); + } else { + return new ResponseEntity<>(new ErrorResponse("Carta no encontrada"), HttpStatus.NOT_FOUND); + } + } + + /** + * Obtiene todas las cartas con paginación + * GET /api/cards?page=0&size=20 + */ + @GetMapping + public ResponseEntity> getAllCards( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + + Pageable pageable = PageRequest.of(page, Math.min(size, 100)); + List cards = cardRepository.findAll(pageable).getContent(); + + return new ResponseEntity<>(cards, HttpStatus.OK); + } + + /** + * Clase interna para errores + */ + static class ErrorResponse { + private String message; + + public ErrorResponse(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + } + + static class CardSearchResponse { + private Long id; + private String name; + private String manaCost; + private String type; + private String imageUrl; + private List colors; + + public CardSearchResponse(Long id, String name, String manaCost, String type, String imageUrl, List colors) { + this.id = id; + this.name = name; + this.manaCost = manaCost; + this.type = type; + this.imageUrl = imageUrl; + this.colors = colors; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getManaCost() { + return manaCost; + } + + public void setManaCost(String manaCost) { + this.manaCost = manaCost; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public List getColors() { + return colors; + } + + public void setColors(List colors) { + this.colors = colors; + } + } +} diff --git a/backend/src/main/java/com/magicvs/backend/dto/CreateDeckDTO.java b/backend/src/main/java/com/magicvs/backend/dto/CreateDeckDTO.java new file mode 100644 index 0000000..c6e65c2 --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/dto/CreateDeckDTO.java @@ -0,0 +1,84 @@ +package com.magicvs.backend.dto; + +import java.util.List; + +public class CreateDeckDTO { + + private String name; + private String description; + private String format; + private Boolean isPublic; + private List cards; + + public static class DeckCardDTO { + private Long cardId; + private Integer quantity; + + public DeckCardDTO() { + } + + public DeckCardDTO(Long cardId, Integer quantity) { + this.cardId = cardId; + this.quantity = quantity; + } + + public Long getCardId() { + return cardId; + } + + public void setCardId(Long cardId) { + this.cardId = cardId; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } + } + + public CreateDeckDTO() { + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getFormat() { + return format; + } + + public void setFormat(String format) { + this.format = format; + } + + public Boolean getIsPublic() { + return isPublic; + } + + public void setIsPublic(Boolean aPublic) { + isPublic = aPublic; + } + + public List getCards() { + return cards; + } + + public void setCards(List cards) { + this.cards = cards; + } +} diff --git a/backend/src/main/java/com/magicvs/backend/dto/DeckResponseDTO.java b/backend/src/main/java/com/magicvs/backend/dto/DeckResponseDTO.java new file mode 100644 index 0000000..81dc75a --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/dto/DeckResponseDTO.java @@ -0,0 +1,199 @@ +package com.magicvs.backend.dto; + +import com.magicvs.backend.model.Deck; +import com.magicvs.backend.model.DeckCard; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +public class DeckResponseDTO { + + private Long id; + private String name; + private String description; + private String format; + private Integer totalCards; + private Boolean isPublic; + private List cards; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static class DeckCardResponseDTO { + private Long cardId; + private String cardName; + private String cardImage; + private String manaCost; + private String cardType; + private Integer quantity; + + public DeckCardResponseDTO() { + } + + public DeckCardResponseDTO(Long cardId, String cardName, String cardImage, String manaCost, String cardType, Integer quantity) { + this.cardId = cardId; + this.cardName = cardName; + this.cardImage = cardImage; + this.manaCost = manaCost; + this.cardType = cardType; + this.quantity = quantity; + } + + public Long getCardId() { + return cardId; + } + + public void setCardId(Long cardId) { + this.cardId = cardId; + } + + public String getCardName() { + return cardName; + } + + public void setCardName(String cardName) { + this.cardName = cardName; + } + + public String getCardImage() { + return cardImage; + } + + public void setCardImage(String cardImage) { + this.cardImage = cardImage; + } + + public String getManaCost() { + return manaCost; + } + + public void setManaCost(String manaCost) { + this.manaCost = manaCost; + } + + public String getCardType() { + return cardType; + } + + public void setCardType(String cardType) { + this.cardType = cardType; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } + } + + public DeckResponseDTO() { + } + + /** + * Convierte una entidad Deck a DeckResponseDTO + */ + public static DeckResponseDTO fromEntity(Deck deck) { + DeckResponseDTO dto = new DeckResponseDTO(); + dto.setId(deck.getId()); + dto.setName(deck.getName()); + dto.setDescription(deck.getDescription()); + dto.setFormat(deck.getFormat().name()); + dto.setTotalCards(deck.getTotalCards()); + dto.setIsPublic(deck.getPublic()); + dto.setCreatedAt(deck.getCreatedAt()); + dto.setUpdatedAt(deck.getUpdatedAt()); + + List cardDtos = deck.getCards().stream() + .map(deckCard -> { + String imageUrl = deckCard.getCard().getFaces().isEmpty() + ? null + : deckCard.getCard().getFaces().get(0).getNormalImageUri(); + + return new DeckCardResponseDTO( + deckCard.getCard().getId(), + deckCard.getCard().getName(), + imageUrl, + deckCard.getCard().getManaCost(), + deckCard.getCard().getTypeLine(), + deckCard.getQuantity() + ); + }) + .collect(Collectors.toList()); + + dto.setCards(cardDtos); + return dto; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getFormat() { + return format; + } + + public void setFormat(String format) { + this.format = format; + } + + public Integer getTotalCards() { + return totalCards; + } + + public void setTotalCards(Integer totalCards) { + this.totalCards = totalCards; + } + + public Boolean getIsPublic() { + return isPublic; + } + + public void setIsPublic(Boolean aPublic) { + isPublic = aPublic; + } + + public List getCards() { + return cards; + } + + public void setCards(List cards) { + this.cards = cards; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/com/magicvs/backend/dto/DeckSummaryDTO.java b/backend/src/main/java/com/magicvs/backend/dto/DeckSummaryDTO.java new file mode 100644 index 0000000..39a34e7 --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/dto/DeckSummaryDTO.java @@ -0,0 +1,73 @@ +package com.magicvs.backend.dto; + +import java.time.LocalDateTime; + +public class DeckSummaryDTO { + + private Long id; + private String name; + private String format; + private Integer totalCards; + private LocalDateTime updatedAt; + private Boolean isPublic; + + public DeckSummaryDTO() { + } + + public DeckSummaryDTO(Long id, String name, String format, Integer totalCards, LocalDateTime updatedAt, Boolean isPublic) { + this.id = id; + this.name = name; + this.format = format; + this.totalCards = totalCards; + this.updatedAt = updatedAt; + this.isPublic = isPublic; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getFormat() { + return format; + } + + public void setFormat(String format) { + this.format = format; + } + + public Integer getTotalCards() { + return totalCards; + } + + public void setTotalCards(Integer totalCards) { + this.totalCards = totalCards; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public Boolean getIsPublic() { + return isPublic; + } + + public void setIsPublic(Boolean aPublic) { + isPublic = aPublic; + } +} diff --git a/backend/src/main/java/com/magicvs/backend/dto/UserDeckSummaryDto.java b/backend/src/main/java/com/magicvs/backend/dto/UserDeckSummaryDto.java index 6b8d99b..d2a8eaa 100644 --- a/backend/src/main/java/com/magicvs/backend/dto/UserDeckSummaryDto.java +++ b/backend/src/main/java/com/magicvs/backend/dto/UserDeckSummaryDto.java @@ -1,6 +1,7 @@ package com.magicvs.backend.dto; import java.time.LocalDateTime; +import java.util.List; public class UserDeckSummaryDto { @@ -10,19 +11,23 @@ public class UserDeckSummaryDto { private String formatName; private Integer totalCards; private Boolean isPublic; + private LocalDateTime createdAt; private LocalDateTime updatedAt; + private List colors; public UserDeckSummaryDto() { } - public UserDeckSummaryDto(Long id, String name, String description, String formatName, Integer totalCards, Boolean isPublic, LocalDateTime updatedAt) { + public UserDeckSummaryDto(Long id, String name, String description, String formatName, Integer totalCards, Boolean isPublic, LocalDateTime createdAt, LocalDateTime updatedAt, List colors) { this.id = id; this.name = name; this.description = description; this.formatName = formatName; this.totalCards = totalCards; this.isPublic = isPublic; + this.createdAt = createdAt; this.updatedAt = updatedAt; + this.colors = colors; } public Long getId() { @@ -73,6 +78,14 @@ public void setIsPublic(Boolean isPublic) { this.isPublic = isPublic; } + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + public LocalDateTime getUpdatedAt() { return updatedAt; } @@ -80,4 +93,12 @@ public LocalDateTime getUpdatedAt() { public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + + public List getColors() { + return colors; + } + + public void setColors(List colors) { + this.colors = colors; + } } diff --git a/backend/src/main/java/com/magicvs/backend/model/Deck.java b/backend/src/main/java/com/magicvs/backend/model/Deck.java new file mode 100644 index 0000000..aa78dc2 --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/model/Deck.java @@ -0,0 +1,154 @@ +package com.magicvs.backend.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "user_decks") +public class Deck { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, length = 120) + private String name; + + @Column(length = 500) + private String description; + + @Enumerated(EnumType.STRING) + @Column(name = "format_name") + private DeckFormat format; + + @Column(name = "total_cards") + private Integer totalCards; + + @Column(name = "is_public") + private Boolean isPublic; + + @OneToMany(mappedBy = "deck", cascade = CascadeType.ALL, orphanRemoval = true) + private Set cards = new HashSet<>(); + + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + public Deck() { + } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + if (this.totalCards == null) { + this.totalCards = 0; + } + if (this.isPublic == null) { + this.isPublic = false; + } + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public void addCard(Card card, Integer quantity) { + DeckCard deckCard = new DeckCard(this, card, Math.min(quantity, 4)); + cards.add(deckCard); + recalculateTotalCards(); + } + + public void removeCard(Long cardId) { + cards.removeIf(dc -> dc.getCard().getId().equals(cardId)); + recalculateTotalCards(); + } + + public void recalculateTotalCards() { + this.totalCards = cards.stream() + .mapToInt(DeckCard::getQuantity) + .sum(); + } + + // Getters and Setters + public Long getId() { + return id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public DeckFormat getFormat() { + return format; + } + + public void setFormat(DeckFormat format) { + this.format = format; + } + + public Integer getTotalCards() { + return totalCards; + } + + public void setTotalCards(Integer totalCards) { + this.totalCards = totalCards; + } + + public Boolean getPublic() { + return isPublic; + } + + public void setPublic(Boolean aPublic) { + isPublic = aPublic; + } + + public Set getCards() { + return cards; + } + + public void setCards(Set cards) { + this.cards = cards; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/com/magicvs/backend/model/DeckCard.java b/backend/src/main/java/com/magicvs/backend/model/DeckCard.java new file mode 100644 index 0000000..fab524c --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/model/DeckCard.java @@ -0,0 +1,63 @@ +package com.magicvs.backend.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "deck_cards", uniqueConstraints = { + @UniqueConstraint(columnNames = {"deck_id", "card_id"}) +}) +public class DeckCard { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "deck_id", nullable = false) + private Deck deck; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "card_id", nullable = false) + private Card card; + + @Column(nullable = false) + private Integer quantity = 1; + + public DeckCard() { + } + + public DeckCard(Deck deck, Card card, Integer quantity) { + this.deck = deck; + this.card = card; + this.quantity = quantity; + } + + // Getters and Setters + public Long getId() { + return id; + } + + public Deck getDeck() { + return deck; + } + + public void setDeck(Deck deck) { + this.deck = deck; + } + + public Card getCard() { + return card; + } + + public void setCard(Card card) { + this.card = card; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } +} diff --git a/backend/src/main/java/com/magicvs/backend/model/DeckFormat.java b/backend/src/main/java/com/magicvs/backend/model/DeckFormat.java new file mode 100644 index 0000000..0a3980f --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/model/DeckFormat.java @@ -0,0 +1,18 @@ +package com.magicvs.backend.model; + +public enum DeckFormat { + STANDARD("Standard"), + MODERN("Modern"), + LEGACY("Legacy"), + PAUPER("Pauper"); + + private final String displayName; + + DeckFormat(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/backend/src/main/java/com/magicvs/backend/repository/CardRepository.java b/backend/src/main/java/com/magicvs/backend/repository/CardRepository.java index d8a87bc..307b448 100644 --- a/backend/src/main/java/com/magicvs/backend/repository/CardRepository.java +++ b/backend/src/main/java/com/magicvs/backend/repository/CardRepository.java @@ -1,7 +1,11 @@ package com.magicvs.backend.repository; import com.magicvs.backend.model.Card; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -9,9 +13,25 @@ public interface CardRepository extends JpaRepository { + interface CardSearchProjection { + Long getId(); + String getName(); + String getManaCost(); + String getTypeLine(); + } + Optional findByScryfallId(UUID scryfallId); List findByNameContainingIgnoreCase(String name); + Page findByNameContainingIgnoreCase(String name, Pageable pageable); + + @Query(""" + SELECT c.id AS id, c.name AS name, c.manaCost AS manaCost, c.typeLine AS typeLine + FROM Card c + WHERE LOWER(c.name) LIKE LOWER(CONCAT('%', :name, '%')) + """) + Page searchProjectedByName(@Param("name") String name, Pageable pageable); + boolean existsByScryfallId(UUID scryfallId); } \ No newline at end of file diff --git a/backend/src/main/java/com/magicvs/backend/repository/DeckCardRepository.java b/backend/src/main/java/com/magicvs/backend/repository/DeckCardRepository.java new file mode 100644 index 0000000..ace6792 --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/repository/DeckCardRepository.java @@ -0,0 +1,13 @@ +package com.magicvs.backend.repository; + +import com.magicvs.backend.model.DeckCard; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface DeckCardRepository extends JpaRepository { + + List findByDeckId(Long deckId); + + void deleteByDeckIdAndCardId(Long deckId, Long cardId); +} diff --git a/backend/src/main/java/com/magicvs/backend/repository/DeckRepository.java b/backend/src/main/java/com/magicvs/backend/repository/DeckRepository.java new file mode 100644 index 0000000..31903e5 --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/repository/DeckRepository.java @@ -0,0 +1,15 @@ +package com.magicvs.backend.repository; + +import com.magicvs.backend.model.Deck; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface DeckRepository extends JpaRepository { + + List findByUserIdOrderByUpdatedAtDesc(Long userId); + + boolean existsByIdAndUserId(Long deckId, Long userId); + + long countByUserId(Long userId); +} diff --git a/backend/src/main/java/com/magicvs/backend/service/DeckService.java b/backend/src/main/java/com/magicvs/backend/service/DeckService.java new file mode 100644 index 0000000..254ed3a --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/service/DeckService.java @@ -0,0 +1,167 @@ +package com.magicvs.backend.service; + +import com.magicvs.backend.dto.CreateDeckDTO; +import com.magicvs.backend.dto.DeckResponseDTO; +import com.magicvs.backend.dto.DeckSummaryDTO; +import com.magicvs.backend.model.Card; +import com.magicvs.backend.model.Deck; +import com.magicvs.backend.model.DeckCard; +import com.magicvs.backend.model.DeckFormat; +import com.magicvs.backend.model.User; +import com.magicvs.backend.repository.CardRepository; +import com.magicvs.backend.repository.DeckCardRepository; +import com.magicvs.backend.repository.DeckRepository; +import com.magicvs.backend.repository.RegistroRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class DeckService { + + private final DeckRepository deckRepository; + private final CardRepository cardRepository; + private final RegistroRepository userRepository; + private final int minDeckCards; + + public DeckService(DeckRepository deckRepository, + CardRepository cardRepository, + RegistroRepository userRepository, + @Value("${deck.validation.min-cards:60}") int minDeckCards) { + this.deckRepository = deckRepository; + this.cardRepository = cardRepository; + this.userRepository = userRepository; + this.minDeckCards = minDeckCards; + } + + /** + * Valida restricciones básicas del mazo antes de guardar + */ + public void validateDeck(CreateDeckDTO deckDTO) throws IllegalArgumentException { + if (deckDTO.getCards() == null || deckDTO.getCards().isEmpty()) { + throw new IllegalArgumentException("El mazo no puede estar vacío"); + } + + // Validar cantidad de cartas + int totalCards = deckDTO.getCards().stream() + .mapToInt(CreateDeckDTO.DeckCardDTO::getQuantity) + .sum(); + if (totalCards < minDeckCards) { + throw new IllegalArgumentException("El mazo debe tener mínimo " + minDeckCards + " cartas. Actual: " + totalCards); + } + + if (totalCards > 250) { + throw new IllegalArgumentException("El mazo no puede exceder 250 cartas. Actual: " + totalCards); + } + + // Validar cantidad máxima por carta (4 copias) + Map cardQuantities = deckDTO.getCards().stream() + .collect(Collectors.groupingBy( + CreateDeckDTO.DeckCardDTO::getCardId, + Collectors.summingInt(CreateDeckDTO.DeckCardDTO::getQuantity) + )); + + for (Map.Entry entry : cardQuantities.entrySet()) { + Card card = cardRepository.findById(entry.getKey()) + .orElseThrow(() -> new IllegalArgumentException("Carta no encontrada: " + entry.getKey())); + + if (entry.getValue() > 4) { + throw new IllegalArgumentException( + "No puedes tener más de 4 copias de '" + card.getName() + "'. Actual: " + entry.getValue()); + } + + if (entry.getValue() < 1) { + throw new IllegalArgumentException( + "La cantidad de cartas debe ser al menos 1 para '" + card.getName() + "'"); + } + } + } + + /** + * Crea un nuevo mazo + */ + @Transactional + public DeckResponseDTO createDeck(Long userId, CreateDeckDTO deckDTO) { + validateDeck(deckDTO); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Usuario no encontrado")); + + Deck deck = new Deck(); + deck.setUser(user); + deck.setName(deckDTO.getName()); + deck.setDescription(deckDTO.getDescription()); + + try { + deck.setFormat(DeckFormat.valueOf(deckDTO.getFormat().toUpperCase())); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Formato de mazo inválido: " + deckDTO.getFormat()); + } + + deck.setPublic(deckDTO.getIsPublic() != null ? deckDTO.getIsPublic() : false); + + // Agregar cartas + for (CreateDeckDTO.DeckCardDTO cardDTO : deckDTO.getCards()) { + Card card = cardRepository.findById(cardDTO.getCardId()) + .orElseThrow(() -> new IllegalArgumentException("Carta no encontrada: " + cardDTO.getCardId())); + deck.addCard(card, cardDTO.getQuantity()); + } + + deck.recalculateTotalCards(); + Deck savedDeck = deckRepository.save(deck); + + return DeckResponseDTO.fromEntity(savedDeck); + } + + /** + * Obtiene un mazo por ID + */ + @Transactional(readOnly = true) + public DeckResponseDTO getDeckById(Long deckId) { + Deck deck = deckRepository.findById(deckId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mazo no encontrado")); + return DeckResponseDTO.fromEntity(deck); + } + + /** + * Obtiene los mazos de un usuario + */ + @Transactional(readOnly = true) + public List getUserDecks(Long userId) { + return deckRepository.findByUserIdOrderByUpdatedAtDesc(userId).stream() + .map(this::toDeckSummary) + .collect(Collectors.toList()); + } + + /** + * Elimina un mazo (solo el propietario) + */ + @Transactional + public void deleteDeck(Long deckId, Long userId) { + Deck deck = deckRepository.findById(deckId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mazo no encontrado")); + + if (!deck.getUser().getId().equals(userId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "No tienes permiso para eliminar este mazo"); + } + + deckRepository.delete(deck); + } + + private DeckSummaryDTO toDeckSummary(Deck deck) { + return new DeckSummaryDTO( + deck.getId(), + deck.getName(), + deck.getFormat().name(), + deck.getTotalCards(), + deck.getUpdatedAt(), + deck.getPublic() + ); + } +} diff --git a/backend/src/main/java/com/magicvs/backend/service/UserProfileService.java b/backend/src/main/java/com/magicvs/backend/service/UserProfileService.java index 0ee1740..32616b6 100644 --- a/backend/src/main/java/com/magicvs/backend/service/UserProfileService.java +++ b/backend/src/main/java/com/magicvs/backend/service/UserProfileService.java @@ -3,25 +3,32 @@ import com.magicvs.backend.dto.ProfileResponseDto; import com.magicvs.backend.dto.UserDeckSummaryDto; import com.magicvs.backend.model.User; -import com.magicvs.backend.model.UserDeck; +import com.magicvs.backend.model.Deck; +import com.magicvs.backend.model.DeckCard; import com.magicvs.backend.repository.RegistroRepository; -import com.magicvs.backend.repository.UserDeckRepository; +import com.magicvs.backend.repository.DeckRepository; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; +import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.LinkedHashSet; @Service +@Transactional(readOnly = true) public class UserProfileService { private final RegistroRepository registroRepository; - private final UserDeckRepository userDeckRepository; + private final DeckRepository deckRepository; private final AuthService authService; - public UserProfileService(RegistroRepository registroRepository, UserDeckRepository userDeckRepository, AuthService authService) { + public UserProfileService(RegistroRepository registroRepository, DeckRepository deckRepository, AuthService authService) { this.registroRepository = registroRepository; - this.userDeckRepository = userDeckRepository; + this.deckRepository = deckRepository; this.authService = authService; } @@ -29,7 +36,7 @@ public ProfileResponseDto getProfileByUserId(Long userId) { User user = registroRepository.findById(userId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Usuario no encontrado")); - long decksCount = userDeckRepository.countByUserId(userId); + long decksCount = deckRepository.countByUserId(userId); return toProfileResponse(user, decksCount); } @@ -43,7 +50,7 @@ public List getDecksByUserId(Long userId) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Usuario no encontrado"); } - return userDeckRepository.findByUserIdOrderByUpdatedAtDesc(userId) + return deckRepository.findByUserIdOrderByUpdatedAtDesc(userId) .stream() .map(this::toDeckSummary) .toList(); @@ -82,15 +89,37 @@ private ProfileResponseDto toProfileResponse(User user, long decksCount) { ); } - private UserDeckSummaryDto toDeckSummary(UserDeck deck) { + private UserDeckSummaryDto toDeckSummary(Deck deck) { return new UserDeckSummaryDto( deck.getId(), deck.getName(), deck.getDescription(), - deck.getFormatName(), + deck.getFormat() != null ? deck.getFormat().name() : null, deck.getTotalCards(), deck.getPublic(), - deck.getUpdatedAt() + deck.getCreatedAt(), + deck.getUpdatedAt(), + extractColors(deck) ); } + + private List extractColors(Deck deck) { + Set colors = new LinkedHashSet<>(); + + for (DeckCard deckCard : deck.getCards()) { + String manaCost = deckCard.getCard().getManaCost(); + if (manaCost == null || manaCost.isBlank()) { + continue; + } + + String upper = manaCost.toUpperCase(Locale.ROOT); + if (upper.contains("{W}")) colors.add("W"); + if (upper.contains("{U}")) colors.add("U"); + if (upper.contains("{B}")) colors.add("B"); + if (upper.contains("{R}")) colors.add("R"); + if (upper.contains("{G}")) colors.add("G"); + } + + return new ArrayList<>(colors); + } } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index c7b5290..6966cd9 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -8,8 +8,11 @@ spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=false spring.jpa.properties.hibernate.format_sql=false +server.port=8080 + +deck.validation.min-cards=${DECK_MIN_CARDS:60} spring.jpa.properties.hibernate.jdbc.batch_size=50 spring.jpa.properties.hibernate.order_inserts=true spring.jpa.properties.hibernate.order_updates=true -server.port=${PORT:8080} \ No newline at end of file +server.port=${PORT:8080} diff --git a/docker-compose.yml b/docker-compose.yml index 42e6163..250da01 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/magicvs SPRING_DATASOURCE_USERNAME: postgres SPRING_DATASOURCE_PASSWORD: postgres + DECK_MIN_CARDS: 60 ports: - "8080:8080" depends_on: diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 77062ff..2fe2db6 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -8,6 +8,7 @@ import { Registro } from './features/registro/registro'; import { CatalogComponent } from './features/catalog/catalog'; import { CardDetailComponent } from './features/catalog/card-detail'; import { ProfilePageComponent } from './features/profile/profile-page.component'; +import { DeckBuilderPageComponent } from './features/deck-builder/deck-builder-page.component'; export const routes: Routes = [ { @@ -23,7 +24,9 @@ export const routes: Routes = [ { path: 'cartas/:id', component: CardDetailComponent }, { path: 'profile', pathMatch: 'full', redirectTo: 'profile/me' }, { path: 'profile/:userId/decks', component: ProfilePageComponent }, - { path: 'profile/:userId', component: ProfilePageComponent } + { path: 'profile/:userId', component: ProfilePageComponent }, + { path: 'decks/create', component: DeckBuilderPageComponent }, + { path: 'decks/:deckId/edit', component: DeckBuilderPageComponent } ] } ]; diff --git a/frontend/src/app/core/services/deck-builder.service.ts b/frontend/src/app/core/services/deck-builder.service.ts new file mode 100644 index 0000000..c20dae2 --- /dev/null +++ b/frontend/src/app/core/services/deck-builder.service.ts @@ -0,0 +1,302 @@ +import { Injectable, signal, computed, effect } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +export interface Card { + id: number; + name: string; + manaCost: string; + type: string; + imageUrl: string; + colors: string[]; +} + +export interface DeckCard { + cardId: number; + cardName: string; + cardImage: string; + manaCost: string; + cardType: string; + quantity: number; +} + +export interface Deck { + id?: number; + name: string; + description: string; + format: string; + isPublic: boolean; + totalCards?: number; + cards: DeckCard[]; +} + +@Injectable({ + providedIn: 'root' +}) +export class DeckBuilderService { + private readonly apiUrl = 'http://localhost:8080/api/decks'; + // Mínimo 60 para alinearse con la validación del backend. + private readonly minDeckCards = 60; + + // Signals + private deckNameSignal = signal('Mi Nuevo Mazo'); + private deckDescriptionSignal = signal(''); + private deckFormatSignal = signal('STANDARD'); + private deckIsPublicSignal = signal(false); + private deckCardsSignal = signal([]); + private loadingSignal = signal(false); + private errorSignal = signal(null); + + // Computed signals + readonly deckName = this.deckNameSignal.asReadonly(); + readonly deckDescription = this.deckDescriptionSignal.asReadonly(); + readonly deckFormat = this.deckFormatSignal.asReadonly(); + readonly deckIsPublic = this.deckIsPublicSignal.asReadonly(); + readonly deckCards = this.deckCardsSignal.asReadonly(); + readonly loading = this.loadingSignal.asReadonly(); + readonly error = this.errorSignal.asReadonly(); + + // Computed: Total cards in deck + readonly totalCards = computed(() => + this.deckCardsSignal().reduce((sum, card) => sum + card.quantity, 0) + ); + + // Computed: Deck colors distribution + readonly deckColors = computed(() => { + const colors = new Set(); + this.deckCardsSignal().forEach(card => { + const cardColors = this.extractColorsFromManaCost(card.manaCost); + cardColors.forEach(color => colors.add(color)); + }); + return Array.from(colors); + }); + + // Computed: Validation status + readonly isValidDeck = computed(() => { + const total = this.totalCards(); + return total >= this.minDeckCards && total <= 250; + }); + + // Computed: Validation message + readonly validationMessage = computed(() => { + const total = this.totalCards(); + if (total === 0) return 'Mazo vacío'; + if (total < this.minDeckCards) return `${total}/${this.minDeckCards} cartas`; + if (total > 250) return `${total}/250 cartas`; + return `${total} cartas`; + }); + + constructor(private http: HttpClient) { + // Log validation changes + effect(() => { + console.log('Deck validation:', { + isValid: this.isValidDeck(), + total: this.totalCards(), + cards: this.deckCards().length + }); + }); + } + + /** + * Actualiza el nombre del mazo + */ + setDeckName(name: string): void { + this.deckNameSignal.set(name); + } + + /** + * Actualiza la descripción del mazo + */ + setDeckDescription(description: string): void { + this.deckDescriptionSignal.set(description); + } + + /** + * Actualiza el formato del mazo + */ + setDeckFormat(format: string): void { + this.deckFormatSignal.set(format); + } + + /** + * Actualiza si el mazo es público + */ + setDeckIsPublic(isPublic: boolean): void { + this.deckIsPublicSignal.set(isPublic); + } + + /** + * Agrega una carta al mazo (con validación de límite de 4 copias) + */ + addCard(card: Card, quantity: number = 1): void { + const existing = this.deckCardsSignal().find(c => c.cardId === card.id); + + if (existing) { + if (existing.quantity + quantity > 4) { + this.errorSignal.set(`No puedes tener más de 4 copias de "${card.name}"`); + return; + } + const updated = this.deckCardsSignal().map(c => + c.cardId === card.id ? { ...c, quantity: c.quantity + quantity } : c + ); + this.deckCardsSignal.set(updated); + } else { + this.deckCardsSignal.update(cards => [ + ...cards, + { + cardId: card.id, + cardName: card.name, + cardImage: card.imageUrl, + manaCost: card.manaCost, + cardType: card.type, + quantity + } + ]); + } + this.errorSignal.set(null); + } + + /** + * Elimina una carta del mazo + */ + removeCard(cardId: number): void { + this.deckCardsSignal.update(cards => + cards.filter(c => c.cardId !== cardId) + ); + } + + /** + * Actualiza la cantidad de una carta + */ + updateCardQuantity(cardId: number, quantity: number): void { + if (quantity < 1) { + this.removeCard(cardId); + return; + } + if (quantity > 4) { + this.errorSignal.set('Máximo 4 copias por carta'); + return; + } + this.deckCardsSignal.update(cards => + cards.map(c => + c.cardId === cardId ? { ...c, quantity } : c + ) + ); + } + + /** + * Limpia el mazo completo + */ + clearDeck(): void { + this.deckCardsSignal.set([]); + this.deckNameSignal.set('Mi Nuevo Mazo'); + this.deckDescriptionSignal.set(''); + this.errorSignal.set(null); + } + + /** + * Carga un mazo existente + */ + loadDeck(deck: Deck): void { + this.deckNameSignal.set(deck.name); + this.deckDescriptionSignal.set(deck.description); + this.deckFormatSignal.set(deck.format); + this.deckIsPublicSignal.set(deck.isPublic); + this.deckCardsSignal.set(deck.cards); + } + + /** + * Guarda el mazo en el servidor + */ + saveDeck(): Observable { + this.loadingSignal.set(true); + this.errorSignal.set(null); + + const deckData = { + name: this.deckNameSignal(), + description: this.deckDescriptionSignal(), + format: this.deckFormatSignal(), + isPublic: this.deckIsPublicSignal(), + cards: this.deckCardsSignal().map(card => ({ + cardId: card.cardId, + quantity: card.quantity + })) + }; + + const token = localStorage.getItem('token') || ''; + if (!token) { + this.loadingSignal.set(false); + const error = new Error('Necesitas iniciar sesión para guardar un mazo'); + this.errorSignal.set(error.message); + return new Observable(observer => observer.error(error)); + } + const headers = new HttpHeaders({ + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }); + + return new Observable(observer => { + this.http.post(this.apiUrl, deckData, { headers }).subscribe({ + next: (response) => { + this.loadingSignal.set(false); + observer.next(response); + observer.complete(); + }, + error: (err) => { + this.loadingSignal.set(false); + const errorMsg = err.error?.message || 'Error al guardar el mazo'; + this.errorSignal.set(errorMsg); + observer.error(err); + } + }); + }); + } + + /** + * Extrae colores del costo de maná + */ + private extractColorsFromManaCost(manaCost: string): string[] { + const colorMap: { [key: string]: string } = { + 'W': 'white', + 'U': 'blue', + 'B': 'black', + 'R': 'red', + 'G': 'green' + }; + + const colors = new Set(); + if (!manaCost) return Array.from(colors); + + // Buscar símbolos de color en el costo de maná (ej: {W}, {U}, {B}, etc) + const matches = manaCost.match(/{([WUBRG])}/g); + if (matches) { + matches.forEach(match => { + const colorCode = match.match(/([WUBRG])/)?.[1]; + if (colorCode && colorMap[colorCode]) { + colors.add(colorMap[colorCode]); + } + }); + } + + return Array.from(colors); + } + + /** + * Obtiene los mazos del usuario + */ + getUserDecks(): Observable { + const token = localStorage.getItem('token') || ''; + const headers = new HttpHeaders({ + 'Authorization': `Bearer ${token}` + }); + + return this.http.get(`${this.apiUrl}/user/me`, { headers }); + } + + /** + * Obtiene un mazo por ID + */ + getDeckById(deckId: number): Observable { + return this.http.get(`${this.apiUrl}/${deckId}`); + } +} diff --git a/frontend/src/app/features/deck-builder/deck-builder-page.component.ts b/frontend/src/app/features/deck-builder/deck-builder-page.component.ts new file mode 100644 index 0000000..054a4ac --- /dev/null +++ b/frontend/src/app/features/deck-builder/deck-builder-page.component.ts @@ -0,0 +1,93 @@ +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; +import { DeckBuilderService, Card } from '../../core/services/deck-builder.service'; +import { DeckSearchPanelComponent } from './deck-search-panel.component'; + +@Component({ + selector: 'app-deck-builder-page', + standalone: true, + imports: [CommonModule, FormsModule, RouterModule, DeckSearchPanelComponent], + templateUrl: './deck-builder-page.html', + styleUrls: ['./deck-builder-page.scss'] +}) +export class DeckBuilderPageComponent { + private deckService = inject(DeckBuilderService); + + // Signals expuestas del servicio + deckName = this.deckService.deckName; + deckDescription = this.deckService.deckDescription; + deckFormat = this.deckService.deckFormat; + deckIsPublic = this.deckService.deckIsPublic; + deckCards = this.deckService.deckCards; + totalCards = this.deckService.totalCards; + deckColors = this.deckService.deckColors; + isValidDeck = this.deckService.isValidDeck; + validationMessage = this.deckService.validationMessage; + loading = this.deckService.loading; + error = this.deckService.error; + + formats = ['STANDARD', 'MODERN', 'LEGACY', 'PAUPER']; + + onNameChange(value: string): void { + this.deckService.setDeckName(value); + } + + onDescriptionChange(value: string): void { + this.deckService.setDeckDescription(value); + } + + onFormatChange(value: string): void { + this.deckService.setDeckFormat(value); + } + + onIsPublicChange(value: boolean): void { + this.deckService.setDeckIsPublic(value); + } + + onCardSelected(card: Card): void { + this.deckService.addCard(card, 1); + } + + onRemoveCard(cardId: number): void { + this.deckService.removeCard(cardId); + } + + onUpdateQuantity(cardId: number, quantity: number): void { + this.deckService.updateCardQuantity(cardId, quantity); + } + + saveDeck(): void { + if (!this.isValidDeck()) { + return; + } + + this.deckService.saveDeck().subscribe({ + next: (response) => { + console.log('Mazo guardado:', response); + // Aquí puedes navegar o mostrar un mensaje de éxito + }, + error: (error) => { + console.error('Error saving deck:', error); + } + }); + } + + clearDeck(): void { + if (confirm('¿Estás seguro de que quieres limpiar el mazo?')) { + this.deckService.clearDeck(); + } + } + + getColorClass(color: string): string { + const colorMap: { [key: string]: string } = { + 'white': 'bg-yellow-100 text-yellow-800', + 'blue': 'bg-blue-100 text-blue-800', + 'black': 'bg-gray-800 text-white', + 'red': 'bg-red-100 text-red-800', + 'green': 'bg-green-100 text-green-800' + }; + return colorMap[color] || 'bg-gray-100 text-gray-800'; + } +} diff --git a/frontend/src/app/features/deck-builder/deck-builder-page.html b/frontend/src/app/features/deck-builder/deck-builder-page.html new file mode 100644 index 0000000..caab700 --- /dev/null +++ b/frontend/src/app/features/deck-builder/deck-builder-page.html @@ -0,0 +1,191 @@ +
+
+ +
+

Deck Builder

+

Construye tu mazo perfecto de Magic

+
+ + + @if (error()) { +
+ {{ error() }} +
+ } + + +
+ +
+ +
+ + +
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+
+ Cartas Total: + + {{ totalCards() }} + +
+
{{ validationMessage() }}
+
+ + + @if (deckColors().length > 0) { +
+
Colores
+
+ @for (color of deckColors(); track color) { + + {{ color }} + + } +
+
+ } + + + + + + +
+
+ + +
+
+

Composición del Mazo

+ + @if (deckCards().length === 0) { +
+

No hay cartas en el mazo

+

Busca y agrega cartas desde el panel izquierdo

+
+ } @else { +
+ @for (card of deckCards(); track card.cardId) { +
+ + + + +
+
{{ card.cardName }}
+
{{ card.cardType }}
+
{{ card.manaCost }}
+
+ + +
+ + + +
+ + + +
+ } +
+ } +
+
+
+
+
diff --git a/frontend/src/app/features/deck-builder/deck-builder-page.scss b/frontend/src/app/features/deck-builder/deck-builder-page.scss new file mode 100644 index 0000000..6e5e44c --- /dev/null +++ b/frontend/src/app/features/deck-builder/deck-builder-page.scss @@ -0,0 +1,120 @@ +.deck-builder-container { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + position: relative; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: url('data:image/svg+xml,'); + pointer-events: none; + opacity: 0.5; + } +} + +/* Input Focus States */ +input:focus, +textarea:focus, +select:focus { + box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.1); + border-color: #a855f7; +} + +/* Scrollbar Styling */ +.max-h-96.overflow-y-auto { + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: #1f2937; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: #4b5563; + border-radius: 4px; + + &:hover { + background: #6b7280; + } + } +} + +/* Card Row Hover Effect */ +.space-y-2 > div { + transition: all 0.2s ease; + + &:hover { + transform: translateX(4px); + box-shadow: 0 4px 12px rgba(168, 85, 247, 0.15); + } +} + +/* Responsive Adjustments */ +@media (max-width: 1024px) { + .deck-builder-container { + padding: 16px; + + h1 { + font-size: 28px; + } + } +} + +@media (max-width: 640px) { + .deck-builder-container { + padding: 12px; + + h1 { + font-size: 24px; + } + } + + .space-y-2 > div { + flex-wrap: wrap; + + .flex-1 { + flex-basis: 100%; + order: 2; + } + + img { + order: 1; + width: 40px; + height: 56px; + } + } +} + +/* Button Animations */ +button { + transition: all 0.2s ease; + + &:hover:not(:disabled) { + transform: translateY(-2px); + } + + &:disabled { + opacity: 0.6; + } +} + +/* Stats Box Animation */ +.bg-gray-800 { + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/frontend/src/app/features/deck-builder/deck-search-panel.component.ts b/frontend/src/app/features/deck-builder/deck-search-panel.component.ts new file mode 100644 index 0000000..95cd033 --- /dev/null +++ b/frontend/src/app/features/deck-builder/deck-search-panel.component.ts @@ -0,0 +1,99 @@ +import { Component, Output, EventEmitter, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { HttpClient } from '@angular/common/http'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { BehaviorSubject, Subject, Observable } from 'rxjs'; + +interface Card { + id: number; + name: string; + manaCost: string; + type: string; + imageUrl: string; + colors: string[]; +} + +@Component({ + selector: 'app-deck-search-panel', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './deck-search-panel.html', + styleUrls: ['./deck-search-panel.scss'] +}) +export class DeckSearchPanelComponent { + @Output() cardSelected = new EventEmitter(); + readonly fallbackImage = 'https://placehold.co/488x680/111827/e5e7eb?text=MagicVS'; + + private http = inject(HttpClient); + private destroyRef = inject(DestroyRef); + + searchQuery = ''; + searchResults$ = new BehaviorSubject([]); + loading = false; + error: string | null = null; + + private searchSubject = new Subject(); + + constructor() { + this.searchSubject.pipe( + debounceTime(300), + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef) + ).subscribe(query => this.performSearch(query)); + } + + onSearchChange(query: string): void { + this.searchQuery = query; + this.searchSubject.next(query); + } + + private performSearch(query: string): void { + if (!query || query.trim().length < 2) { + this.searchResults$.next([]); + return; + } + + this.loading = true; + this.error = null; + + // TODO: Reemplazar con la URL real del API + const apiUrl = `http://localhost:8080/api/cards/search?name=${encodeURIComponent(query)}`; + + this.http.get(apiUrl).pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: (results) => { + this.searchResults$.next(results); + this.loading = false; + }, + error: () => { + console.error('Search error occurred'); + this.error = 'Error en la búsqueda de cartas'; + this.loading = false; + } + }); + } + + selectCard(card: Card): void { + this.cardSelected.emit(card); + this.searchQuery = ''; + this.searchResults$.next([]); + } + + clearSearch(): void { + this.searchQuery = ''; + this.searchResults$.next([]); + this.error = null; + } + + onImageError(event: Event): void { + const target = event.target as HTMLImageElement | null; + if (!target) { + return; + } + + target.src = this.fallbackImage; + } +} diff --git a/frontend/src/app/features/deck-builder/deck-search-panel.html b/frontend/src/app/features/deck-builder/deck-search-panel.html new file mode 100644 index 0000000..afdfa9a --- /dev/null +++ b/frontend/src/app/features/deck-builder/deck-search-panel.html @@ -0,0 +1,93 @@ +
+

Buscar Cartas

+ + +
+
+ + @if (searchQuery) { + + } +
+
+ + + @if (error) { +
+ {{ error }} +
+ } + + + @if (loading) { +
+
+
+
+
+

Buscando cartas...

+
+
+ } + + + @else { + @let results = (searchResults$ | async); + @if (results && results.length === 0 && searchQuery) { +
+

No se encontraron cartas

+
+ } @else if (results && results.length > 0) { +
+ @for (card of results; track card.id) { + +
+ + } +
+ } @else { +
+

Escribe un nombre de carta para buscar

+
+ } + } + diff --git a/frontend/src/app/features/deck-builder/deck-search-panel.scss b/frontend/src/app/features/deck-builder/deck-search-panel.scss new file mode 100644 index 0000000..8b8de17 --- /dev/null +++ b/frontend/src/app/features/deck-builder/deck-search-panel.scss @@ -0,0 +1,79 @@ +.search-panel { + display: flex; + flex-direction: column; + + /* Scrollbar Styling */ + .space-y-2 { + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: #1f2937; + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: #4b5563; + border-radius: 3px; + + &:hover { + background: #6b7280; + } + } + } + + /* Card Result Buttons */ + button[class*="hover:bg-gray"] { + transition: all 0.2s ease; + + &:hover { + transform: translateX(4px); + box-shadow: 0 4px 12px rgba(168, 85, 247, 0.15); + } + + img { + transition: transform 0.2s ease; + } + + &:hover img { + transform: scale(1.05); + } + } + + /* Loading Animation */ + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + .animate-spin { + animation: spin 1s linear infinite; + } + + /* Input Field Focus */ + input:focus { + box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.1); + } +} + +/* Responsive */ +@media (max-width: 1024px) { + .search-panel { + max-height: 400px; + } +} + +@media (max-width: 640px) { + .search-panel { + max-height: 300px; + + h2 { + font-size: 18px; + } + } +} diff --git a/frontend/src/app/features/login/login.ts b/frontend/src/app/features/login/login.ts index b46d59c..98894b0 100644 --- a/frontend/src/app/features/login/login.ts +++ b/frontend/src/app/features/login/login.ts @@ -35,6 +35,7 @@ export class Login { // Guardamos token y usuario en localStorage y navegamos al Home if (user.token) { localStorage.setItem('token', user.token); + localStorage.setItem('authToken', user.token); } localStorage.setItem('user', JSON.stringify(user)); this.router.navigateByUrl('/'); diff --git a/frontend/src/app/features/profile/profile-page.component.ts b/frontend/src/app/features/profile/profile-page.component.ts index 9dfea70..1db1bf8 100644 --- a/frontend/src/app/features/profile/profile-page.component.ts +++ b/frontend/src/app/features/profile/profile-page.component.ts @@ -70,6 +70,12 @@ export class ProfilePageComponent implements OnInit, OnDestroy { this.profile.set(null); this.decks.set([]); + if (target === 'me' && !localStorage.getItem('token')) { + this.loading.set(false); + this.error.set('Necesitas iniciar sesión para ver tu perfil.'); + return; + } + if (target !== 'me' && !/^\d+$/.test(target)) { this.loading.set(false); this.error.set('El identificador de usuario no es valido. Usa /profile/me/decks o /profile/{id}/decks con un id numerico.'); diff --git a/frontend/src/app/layouts/main-layout/main-layout.html b/frontend/src/app/layouts/main-layout/main-layout.html index 2f4ca3c..393eba7 100644 --- a/frontend/src/app/layouts/main-layout/main-layout.html +++ b/frontend/src/app/layouts/main-layout/main-layout.html @@ -13,7 +13,7 @@ routerLink="/noticias" routerLinkActive="text-purple-400 border-purple-500 font-bold">Noticias Mazos