Display list of games (#16)

Co-authored-by: Maxime Adler <madler@sqli.com>
Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
2025-04-17 00:59:30 +02:00
parent 79a9bb91d7
commit d90811723a
25 changed files with 438 additions and 55 deletions

View File

@@ -9,4 +9,17 @@ public static class GameHelper
game.CreationUserId = 100000; game.CreationUserId = 100000;
game.CreationDate = DateTime.Now; game.CreationDate = DateTime.Now;
} }
public static string GetInterestColor(int interest, int maxInterest)
{
int firstTier = (int)Math.Floor(0.33 * maxInterest);
int secondTier = (int)Math.Ceiling(0.66 * maxInterest);
return interest switch
{
int x when x <= firstTier => "--red",
int x when x >= secondTier => "--green",
_ => "--yellow",
};
}
} }

View File

@@ -0,0 +1,30 @@
using GameIdeas.Shared.Dto;
using Microsoft.AspNetCore.Components;
namespace GameIdeas.BlazorApp.Pages.Games.Components;
public class GameBase : ComponentBase
{
[Parameter] public GameDto GameDto { get; set; } = new();
[Inject] public NavigationManager NavigationManager { get; set; } = default!;
protected void HandleDetailClicked()
{
NavigationManager.NavigateTo($"/Games/Detail/{GameDto.Id}");
}
protected string GetFormatedStorageSpace()
{
if (GameDto.StorageSpace == null)
{
return string.Empty;
}
return GameDto.StorageSpace switch
{
>= 1000000 => $"{GameDto.StorageSpace / 1000000:f1} To",
>= 1000 => $"{GameDto.StorageSpace / 1000:f1} Go",
_ => $"{GameDto.StorageSpace:f1} Mo"
};
}
}

View File

@@ -0,0 +1,41 @@
@using GameIdeas.BlazorApp.Helpers
@using GameIdeas.BlazorApp.Shared.Constants
@inherits GameBase
<div class="row">
<img class="icon" src="~/icon.png" />
<a class="title" href="@($"/Games/Detail/{GameDto.Id}")">@GameDto.Title</a>
<span class="release-date">@(GameDto.ReleaseDate?.ToShortDateString() ?? @ResourcesKey.Unknown)</span>
<div class="platforms">
@foreach (var platform in GameDto.Platforms ?? [])
{
<a href="@platform.Url"
class="pill @(string.IsNullOrEmpty(platform.Url) ? "" : "platform-pill")">
@platform.Label
</a>
}
</div>
<div class="tags">
@foreach (var tag in GameDto.Tags ?? [])
{
<div class="pill">
@tag.Label
</div>
}
</div>
<span class="storage">@GetFormatedStorageSpace()</span>
<div class="interest">
<span class="value" style="@($"color: var({GameHelper.GetInterestColor(GameDto.Interest, 5)})")">
@GameDto.Interest
</span>
<span class="max-value">/5</span>
</div>
<button class="detail">@Icons.Triangle</button>
</div>

View File

@@ -0,0 +1,101 @@
.row {
display: grid;
grid-template-columns: auto 3fr 70px 2fr 3fr 60px 30px 30px;
grid-gap: 8px;
text-wrap: nowrap;
height: 64px;
background: var(--input-secondary);
box-shadow: var(--drop-shadow);
border-radius: var(--big-radius);
align-items: center;
overflow: hidden;
}
.row * {
max-height: 64px;
height: fit-content;
padding: 6px 0;
box-sizing: border-box;
}
.icon {
padding: 0;
margin: 8px;
height: 48px;
width: 48px;
}
.title {
font-weight: bold;
font-size: 13px;
color: var(--white);
text-decoration: none;
width: fit-content;
padding: 6px;
border-radius: var(--small-radius);
}
.title:hover {
background: var(--input-selected);
}
.release-date, .storage, .max-value {
color: rgb(184, 184, 184);
}
.pill {
width: fit-content;
height: 24px;
padding: 0 6px;
background: rgb(255, 255, 255, 0.2);
border-radius: var(--small-radius);
align-content: center;
}
.platforms, .tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.platform-pill {
color: var(--violet);
cursor: pointer;
text-decoration: none;
}
.platform-pill:hover {
text-decoration: underline;
}
.detail {
transform: scale(0.6, 1) rotate(-90deg);
background: none;
border: none;
outline: none;
cursor: pointer;
}
::deep .detail svg {
fill: var(--white);
}
.value {
font-size: 24px;
font-weight: bold;
}
.max-value {
position: absolute;
transform: translate(2px, 10px);
}
@media screen and (max-width: 1000px) {
.row {
grid-template-columns: 48px 3fr 2fr 3fr 30px 30px;
}
.tags, .storage {
display: none;
}
}

View File

@@ -0,0 +1,36 @@
@using GameIdeas.BlazorApp.Helpers
@using GameIdeas.BlazorApp.Shared.Constants
<div class="row">
<div class="icon">@Icons.Game</div>
<div class="title"></div>
<span class="release-date">@ResourcesKey.Unknown</span>
<div class="platforms">
<div class="pill"></div>
<div class="pill pill-sm"></div>
<div class="pill pill-lg"></div>
</div>
<div class="tags">
<div class="pill"></div>
<div class="pill lg-pill"></div>
</div>
<span class="storage">10.0 Go</span>
<div class="interest">
<span class="value" style="@($"color: var({GameHelper.GetInterestColor(Interest, 5)})")">
@Interest
</span>
<span class="max-value">/5</span>
</div>
<button class="detail">@Icons.Triangle</button>
</div>
@code {
private int Interest = @Random.Shared.Next(1, 5);
}

View File

@@ -0,0 +1,111 @@
.row {
display: grid;
grid-template-columns: auto 3fr 70px 2fr 3fr 60px 30px 30px;
grid-gap: 8px;
height: 64px;
background: var(--input-secondary);
box-shadow: var(--drop-shadow);
border-radius: var(--big-radius);
align-items: center;
overflow: hidden;
}
.row * {
max-height: 64px;
height: fit-content;
padding: 6px 0;
box-sizing: border-box;
}
.icon {
border-radius: var(--small-radius);
padding: 4px;
margin: 8px;
height: 48px;
width: 48px;
animation: loading 3s ease infinite;
}
.icon ::deep svg {
fill: var(--input-secondary);
}
.title {
border-radius: var(--small-radius);
animation: loading 3s ease infinite;
width: 160px;
height: 24px;
padding: 6px;
border-radius: var(--small-radius);
}
.release-date, .storage, .max-value {
color: rgb(184, 184, 184);
}
.pill {
width: 60px;
height: 24px;
padding: 0 6px;
border-radius: var(--small-radius);
align-content: center;
animation: loading 3s ease infinite;
}
.pill-lg {
width: 80px;
}
.pill-sm {
width: 40px;
}
.platforms, .tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.detail {
transform: scale(0.6, 1) rotate(-90deg);
background: none;
border: none;
outline: none;
cursor: pointer;
}
::deep .detail svg {
fill: var(--white);
}
.value {
font-size: 24px;
font-weight: bold;
}
.max-value {
position: absolute;
transform: translate(2px, 10px);
}
@media screen and (max-width: 1000px) {
.row {
grid-template-columns: 48px 3fr 2fr 3fr 30px 30px;
}
.tags, .storage {
display: none;
}
}
@keyframes loading {
0% {
background: rgb(255, 255, 255, 0.05);
}
50% {
background: rgb(255, 255, 255, 0.2);
}
100% {
background: rgb(255, 255, 255, 0.05);
}
}

View File

@@ -4,9 +4,9 @@
gap: 10px; gap: 10px;
padding-right: 20px; padding-right: 20px;
padding-left: 10px; padding-left: 10px;
height: 100%; min-height: calc(100vh - 80px);
box-sizing: border-box; box-sizing: border-box;
width: 240px; width: 100%;
border-left: 2px solid var(--line); border-left: 2px solid var(--line);
z-index: var(--index-content); z-index: var(--index-content);
} }
@@ -19,6 +19,7 @@
.title { .title {
font-weight: bold; font-weight: bold;
font-size: 14px;
color: var(--violet); color: var(--violet);
height: 24px; height: 24px;
align-content: center align-content: center

View File

@@ -2,8 +2,8 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 8px; gap: 8px;
margin: 0 8px;
align-items: center; align-items: center;
z-index: var(--index-content);
} }
.search-container { .search-container {
@@ -13,7 +13,7 @@
.slider-container { .slider-container {
width: 150px; width: 150px;
min-width: 150px; min-width: 150px;
} }
.select-container { .select-container {

View File

@@ -14,16 +14,31 @@
<GameHeader AddTypeChanged="HandleAddClicked"> <GameHeader AddTypeChanged="HandleAddClicked">
<GameFilter Categories="Categories" <GameFilter Categories="Categories"
@bind-DisplayType=DisplayType @bind-DisplayType=DisplayType
@bind-Value=GameFilter/> @bind-Value=GameFilter />
</GameHeader> </GameHeader>
<div class="container"> <div class="container">
<div class="content"> <div class="content">
@if (!IsLoading)
{
@foreach (var game in GamesDto)
{
<GameRow GameDto="game" />
}
}
else
{
@for (int i = 0; i < 20; i++)
{
<GameRowSkeleton />
}
}
</div> </div>
<AdvancedGameFilter @bind-GameFilter=GameFilter Categories="Categories" /> <AdvancedGameFilter @bind-GameFilter=GameFilter Categories="Categories" />
</div> </div>
<Popup @ref=ManualAddPopup BackdropFilterClicked="HandleBackdropManualAddClicked" Closable=false> <Popup @ref=ManualAddPopup BackdropFilterClicked="HandleBackdropManualAddClicked" Closable=false>
<GameCreationForm Categories="Categories" OnSubmit="HandleFetchCategories" /> <GameCreationForm Categories="Categories" OnSubmit="HandleFetchDatas" />
</Popup> </Popup>

View File

@@ -6,18 +6,20 @@ using Microsoft.AspNetCore.Components;
namespace GameIdeas.BlazorApp.Pages.Games; namespace GameIdeas.BlazorApp.Pages.Games;
public partial class GameBase () public partial class Game
{ {
[Inject] private IGameGateway GameGateway { get; set; } = default!; [Inject] private IGameGateway GameGateway { get; set; } = default!;
private DisplayType DisplayType = DisplayType.List; private DisplayType DisplayType = DisplayType.List;
private GameFilterDto GameFilter = new(); private GameFilterDto GameFilter = new();
private Popup? ManualAddPopup; private Popup? ManualAddPopup;
private bool IsLoading = false;
private CategoriesDto? Categories; private CategoriesDto? Categories;
private IEnumerable<GameDto> GamesDto = [];
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await HandleFetchCategories(); await HandleFetchDatas();
await base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
private void HandleAddClicked(AddType addType) private void HandleAddClicked(AddType addType)
@@ -37,8 +39,22 @@ public partial class GameBase ()
{ {
ManualAddPopup?.Close(); ManualAddPopup?.Close();
} }
private async Task HandleFetchCategories() private async Task HandleFetchDatas()
{ {
Categories = await GameGateway.FetchCategories(); try
{
IsLoading = true;
Categories = await GameGateway.FetchCategories();
GamesDto = await GameGateway.FetchGames(new PaggingDto() { CurrentPage = 1, NumberPerPage = 50 });
}
catch (Exception)
{
throw;
}
finally
{
IsLoading = false;
}
} }
} }

View File

@@ -0,0 +1,14 @@
.container {
display: grid;
grid-template-columns: 1fr 240px;
padding: 20px 0;
height: fit-content;
}
.content {
z-index: var(--index-content);
margin: 0 20px;
display: flex;
flex-direction: column;
gap: 20px;
}

View File

@@ -1,12 +0,0 @@
.container {
margin-top: 20px;
margin-bottom: 10px;
justify-content: space-between;
display: flex;
flex-direction: row;
height: 100%;
}
.content {
z-index: var(--index-content);
}

View File

@@ -33,4 +33,17 @@ public class GameGateway(IHttpClientService httpClientService) : IGameGateway
throw new CategoryNotFoundException(ResourcesKey.ErrorFetchCategories); throw new CategoryNotFoundException(ResourcesKey.ErrorFetchCategories);
} }
} }
public async Task<IEnumerable<GameDto>> FetchGames(PaggingDto pagging)
{
try
{
var result = await httpClientService.FetchDataAsync<IEnumerable<GameDto>>(Endpoints.Game.Fetch(pagging));
return result ?? throw new InvalidOperationException(ResourcesKey.ErrorFetchGames);
}
catch (Exception)
{
throw new CategoryNotFoundException(ResourcesKey.ErrorFetchGames);
}
}
} }

View File

@@ -6,4 +6,5 @@ public interface IGameGateway
{ {
Task<CategoriesDto> FetchCategories(); Task<CategoriesDto> FetchCategories();
Task<int> CreateGame(GameDto game); Task<int> CreateGame(GameDto game);
Task<IEnumerable<GameDto>> FetchGames(PaggingDto pagging);
} }

View File

@@ -8,7 +8,7 @@
<div class="popup-content"> <div class="popup-content">
@if (Closable) @if (Closable)
{ {
<button @onclick="HandleBackdropFilterClicked">@Icons.Shared.Close</button> <button @onclick="HandleBackdropFilterClicked">@Icons.Close</button>
} }
@ChildContent @ChildContent
</div> </div>

View File

@@ -17,7 +17,7 @@
@if (!string.IsNullOrEmpty(Text)) @if (!string.IsNullOrEmpty(Text))
{ {
<div class="clear-icon" @onclick=HandleClearClicked> <div class="clear-icon" @onclick=HandleClearClicked>
@Icons.Shared.Close; @Icons.Close;
</div> </div>
} }

View File

@@ -57,8 +57,8 @@ public partial class SearchInput
{ {
return Icon switch return Icon switch
{ {
SearchInputIcon.Dropdown => Icons.Search.Triangle, SearchInputIcon.Dropdown => Icons.Triangle,
SearchInputIcon.Search => Icons.Search.Glass, SearchInputIcon.Search => Icons.Glass,
_ => new MarkupString() _ => new MarkupString()
}; };
} }

View File

@@ -1,3 +1,4 @@
using GameIdeas.BlazorApp.Helpers;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace GameIdeas.BlazorApp.Shared.Components.Slider; namespace GameIdeas.BlazorApp.Shared.Components.Slider;
@@ -16,15 +17,6 @@ public partial class Slider
private string StatusColor(int value) private string StatusColor(int value)
{ {
string str = "--thumb-color: var({0});"; string str = "--thumb-color: var({0});";
return string.Format(str, GameHelper.GetInterestColor(value, Params.Max));
int firstTier = (int)Math.Floor(0.33 * Params.Max);
int secondTier = (int)Math.Ceiling(0.66 * Params.Max);
return value switch
{
int x when x <= firstTier => string.Format(str, "--red"),
int x when x >= secondTier => string.Format(str, "--green"),
_ => string.Format(str, "--yellow"),
};
} }
} }

View File

@@ -1,10 +1,14 @@
namespace GameIdeas.BlazorApp.Shared.Constants; using GameIdeas.Shared.Dto;
namespace GameIdeas.BlazorApp.Shared.Constants;
public static class Endpoints public static class Endpoints
{ {
public static class Game public static class Game
{ {
public static readonly string Create = "api/Game/Create"; public static readonly string Create = "api/Game/Create";
public static string Fetch(PaggingDto pagging) =>
$"api/Game?{nameof(pagging.CurrentPage)}={pagging.CurrentPage}&{nameof(pagging.NumberPerPage)}={pagging.NumberPerPage}";
} }
public static class Category public static class Category

View File

@@ -8,21 +8,19 @@ public static class Icons
private const string CloseBraket = "</svg>"; private const string CloseBraket = "</svg>";
public static class Search public readonly static MarkupString Glass = new(OpenBraket +
{ "<path d=\"M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z\" />" +
public readonly static MarkupString Glass = new(OpenBraket + CloseBraket);
"<path d=\"M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z\" />" +
CloseBraket);
public readonly static MarkupString Triangle = new(OpenBraket + public readonly static MarkupString Triangle = new(OpenBraket +
"<path d=\"M1 3H23L12 22\" />" + "<path d=\"M1 3H23L12 22\" />" +
CloseBraket); CloseBraket);
}
public readonly static MarkupString Close = new(OpenBraket +
"<path d=\"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z\" />" +
CloseBraket);
public static class Shared public readonly static MarkupString Game = new(OpenBraket +
{ "<path d=\"M6,7H18A5,5 0 0,1 23,12A5,5 0 0,1 18,17C16.36,17 14.91,16.21 14,15H10C9.09,16.21 7.64,17 6,17A5,5 0 0,1 1,12A5,5 0 0,1 6,7M19.75,9.5A1.25,1.25 0 0,0 18.5,10.75A1.25,1.25 0 0,0 19.75,12A1.25,1.25 0 0,0 21,10.75A1.25,1.25 0 0,0 19.75,9.5M17.25,12A1.25,1.25 0 0,0 16,13.25A1.25,1.25 0 0,0 17.25,14.5A1.25,1.25 0 0,0 18.5,13.25A1.25,1.25 0 0,0 17.25,12M5,9V11H3V13H5V15H7V13H9V11H7V9H5Z\">" +
public readonly static MarkupString Close = new(OpenBraket + CloseBraket);
"<path d=\"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z\" />" +
CloseBraket);
}
} }

View File

@@ -0,0 +1,3 @@
namespace GameIdeas.BlazorApp.Shared.Exceptions;
public class GameNotFoundException(string message) : Exception(message);

View File

@@ -39,7 +39,6 @@ html {
html, body, #app { html, body, #app {
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100%;
} }
.invalid { .invalid {

View File

@@ -38,6 +38,8 @@ public class Translations (TranslationService translationService)
public string ErrorCreateGame => translationService.Translate(nameof(ErrorCreateGame)); public string ErrorCreateGame => translationService.Translate(nameof(ErrorCreateGame));
public string InvalidTitle => translationService.Translate(nameof(InvalidTitle)); public string InvalidTitle => translationService.Translate(nameof(InvalidTitle));
public string InvalidInterest => translationService.Translate(nameof(InvalidInterest)); public string InvalidInterest => translationService.Translate(nameof(InvalidInterest));
public string Unknown => translationService.Translate(nameof(Unknown));
public string ErrorFetchGames => translationService.Translate(nameof(ErrorFetchGames));
} }
public static class ResourcesKey public static class ResourcesKey
@@ -84,4 +86,6 @@ public static class ResourcesKey
public static string ErrorCreateGame => _instance?.ErrorCreateGame ?? throw new InvalidOperationException("ResourcesKey.ErrorCreateGame is not initialized."); public static string ErrorCreateGame => _instance?.ErrorCreateGame ?? throw new InvalidOperationException("ResourcesKey.ErrorCreateGame is not initialized.");
public static string InvalidTitle => _instance?.InvalidTitle ?? throw new InvalidOperationException("ResourcesKey.InvalidTitle is not initialized."); public static string InvalidTitle => _instance?.InvalidTitle ?? throw new InvalidOperationException("ResourcesKey.InvalidTitle is not initialized.");
public static string InvalidInterest => _instance?.InvalidInterest ?? throw new InvalidOperationException("ResourcesKey.InvalidInterest is not initialized."); public static string InvalidInterest => _instance?.InvalidInterest ?? throw new InvalidOperationException("ResourcesKey.InvalidInterest is not initialized.");
public static string Unknown => _instance?.Unknown ?? throw new InvalidOperationException("ResourcesKey.Unknown is not initialized.");
public static string ErrorFetchGames => _instance?.ErrorFetchGames ?? throw new InvalidOperationException("ResourcesKey.ErrorFetchGames is not initialized.");
} }

View File

@@ -12,7 +12,7 @@ public class GameController(IGameService gameService, ILoggerFactory loggerFacto
private readonly ILogger<GameController> logger = loggerFactory.CreateLogger<GameController>(); private readonly ILogger<GameController> logger = loggerFactory.CreateLogger<GameController>();
[HttpGet] [HttpGet]
public async Task<ActionResult<IEnumerable<GameDto>>> SearchGames([FromQuery] PaggingDto pagging) public async Task<ActionResult<IEnumerable<GameDto>>> GetGames([FromQuery] PaggingDto pagging)
{ {
try try
{ {

View File

@@ -33,5 +33,8 @@
"PlaceholderAdd": "Ajouter un nouveau", "PlaceholderAdd": "Ajouter un nouveau",
"ErrorCreateGame": "Erreur lors de la Création d'un jeu", "ErrorCreateGame": "Erreur lors de la Création d'un jeu",
"InvalidTitle": "Le titre est incorrect", "InvalidTitle": "Le titre est incorrect",
"InvalidInterest": "L'interêt est incorrect'" "InvalidInterest": "L'interêt est incorrect",
"Unknown": "Inconnu",
"ErrorFetchGames": "Erreur lors de la récupération des jeux"
} }