genre page added

This commit is contained in:
2024-11-08 17:53:23 +01:00
Unverified
parent 9f2ccadff7
commit 2d5b996337
20 changed files with 870 additions and 355 deletions

View File

@@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using WatchIt.Common.Model.Genres;
using WatchIt.Common.Model.Media;
using WatchIt.WebAPI.Services.Controllers.Genres;
namespace WatchIt.WebAPI.Controllers;
@@ -10,16 +11,20 @@ namespace WatchIt.WebAPI.Controllers;
[Route("genres")]
public class GenresController(IGenresControllerService genresControllerService) : ControllerBase
{
#region METHODS
#region Main
[HttpGet]
[AllowAnonymous]
[ProducesResponseType(typeof(IEnumerable<GenreResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult> GetAll(GenreQueryParameters query) => await genresControllerService.GetAll(query);
public async Task<ActionResult> GetGenres(GenreQueryParameters query) => await genresControllerService.GetGenres(query);
[HttpGet("{id}")]
[AllowAnonymous]
[ProducesResponseType(typeof(GenreResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Get([FromRoute]short id) => await genresControllerService.Get(id);
public async Task<ActionResult> GetGenre([FromRoute]short id) => await genresControllerService.GetGenre(id);
[HttpPost]
[Authorize]
@@ -27,7 +32,7 @@ public class GenresController(IGenresControllerService genresControllerService)
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)]
public async Task<ActionResult> Post([FromBody]GenreRequest body) => await genresControllerService.Post(body);
public async Task<ActionResult> PostGenre([FromBody]GenreRequest body) => await genresControllerService.PostGenre(body);
[HttpPut("{id}")]
[Authorize]
@@ -35,7 +40,7 @@ public class GenresController(IGenresControllerService genresControllerService)
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Put([FromRoute]short id, [FromBody]GenreRequest body) => await genresControllerService.Put(id, body);
public async Task<ActionResult> PutGenre([FromRoute]short id, [FromBody]GenreRequest body) => await genresControllerService.PutGenre(id, body);
[HttpDelete("{id}")]
[Authorize]
@@ -44,5 +49,19 @@ public class GenresController(IGenresControllerService genresControllerService)
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Delete([FromRoute]short id) => await genresControllerService.Delete(id);
public async Task<ActionResult> DeleteGenre([FromRoute]short id) => await genresControllerService.DeleteGenre(id);
#endregion
#region Media
[HttpGet("{id}/media")]
[AllowAnonymous]
[ProducesResponseType(typeof(IEnumerable<MediaResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> GetGenreMedia([FromRoute]short id, MediaQueryParameters query) => await genresControllerService.GetGenreMedia(id, query);
#endregion
#endregion
}

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using WatchIt.Common.Model.Genres;
using WatchIt.Common.Model.Media;
using WatchIt.Database;
using WatchIt.Database.Model.Media;
using WatchIt.WebAPI.Services.Controllers.Common;
@@ -12,14 +13,16 @@ public class GenresControllerService(DatabaseContext database, IUserService user
{
#region PUBLIC METHODS
public async Task<RequestResult> GetAll(GenreQueryParameters query)
#region Main
public async Task<RequestResult> GetGenres(GenreQueryParameters query)
{
IEnumerable<GenreResponse> data = await database.Genres.Select(x => new GenreResponse(x)).ToListAsync();
data = query.PrepareData(data);
return RequestResult.Ok(data);
}
public async Task<RequestResult> Get(short id)
public async Task<RequestResult> GetGenre(short id)
{
Genre? item = await database.Genres.FirstOrDefaultAsync(x => x.Id == id);
if (item is null)
@@ -31,7 +34,7 @@ public class GenresControllerService(DatabaseContext database, IUserService user
return RequestResult.Ok(data);
}
public async Task<RequestResult> Post(GenreRequest data)
public async Task<RequestResult> PostGenre(GenreRequest data)
{
UserValidator validator = userService.GetValidator().MustBeAdmin();
if (!validator.IsValid)
@@ -46,7 +49,7 @@ public class GenresControllerService(DatabaseContext database, IUserService user
return RequestResult.Created($"genres/{item.Id}", new GenreResponse(item));
}
public async Task<RequestResult> Put(short id, GenreRequest data)
public async Task<RequestResult> PutGenre(short id, GenreRequest data)
{
UserValidator validator = userService.GetValidator().MustBeAdmin();
if (!validator.IsValid)
@@ -66,7 +69,7 @@ public class GenresControllerService(DatabaseContext database, IUserService user
return RequestResult.Ok();
}
public async Task<RequestResult> Delete(short id)
public async Task<RequestResult> DeleteGenre(short id)
{
UserValidator validator = userService.GetValidator().MustBeAdmin();
if (!validator.IsValid)
@@ -90,4 +93,24 @@ public class GenresControllerService(DatabaseContext database, IUserService user
}
#endregion
#region Media
public async Task<RequestResult> GetGenreMedia(short id, MediaQueryParameters query)
{
if (!database.Genres.Any(x => x.Id == id))
{
return RequestResult.NotFound();
}
IEnumerable<Database.Model.Media.Media> rawData = await database.Media.Where(x => x.MediaGenres.Any(y => y.GenreId == id))
.ToListAsync();
IEnumerable<MediaResponse> data = rawData.Select(x => new MediaResponse(x, database.MediaMovies.Any(y => y.Id == x.Id) ? MediaType.Movie : MediaType.Series));
data = query.PrepareData(data);
return RequestResult.Ok(data);
}
#endregion
#endregion
}

View File

@@ -1,13 +1,15 @@
using WatchIt.Common.Model.Genres;
using WatchIt.Common.Model.Media;
using WatchIt.WebAPI.Services.Controllers.Common;
namespace WatchIt.WebAPI.Services.Controllers.Genres;
public interface IGenresControllerService
{
Task<RequestResult> GetAll(GenreQueryParameters query);
Task<RequestResult> Get(short id);
Task<RequestResult> Post(GenreRequest data);
Task<RequestResult> Put(short id, GenreRequest data);
Task<RequestResult> Delete(short id);
Task<RequestResult> GetGenres(GenreQueryParameters query);
Task<RequestResult> GetGenre(short id);
Task<RequestResult> PostGenre(GenreRequest data);
Task<RequestResult> PutGenre(short id, GenreRequest data);
Task<RequestResult> DeleteGenre(short id);
Task<RequestResult> GetGenreMedia(short id, MediaQueryParameters query);
}

View File

@@ -0,0 +1,116 @@
using WatchIt.Common.Model.Genders;
using WatchIt.Common.Model.Genres;
using WatchIt.Common.Model.Media;
using WatchIt.Common.Services.HttpClient;
using WatchIt.Website.Services.Configuration;
namespace WatchIt.Website.Services.Client.Genres;
public class GenresClientService : BaseClientService, IGenresClientService
{
#region SERVICES
private IHttpClientService _httpClientService;
private IConfigurationService _configurationService;
#endregion
#region CONSTRUCTORS
public GenresClientService(IHttpClientService httpClientService, IConfigurationService configurationService) : base(configurationService)
{
_httpClientService = httpClientService;
_configurationService = configurationService;
}
#endregion
#region PUBLIC METHODS
#region Main
public async Task GetGenres(GenreQueryParameters? query = null, Action<IEnumerable<GenreResponse>>? successAction = null)
{
string url = GetUrl(EndpointsConfiguration.Genres.GetGenres);
HttpRequest request = new HttpRequest(HttpMethodType.Get, url);
request.Query = query;
HttpResponse response = await _httpClientService.SendRequestAsync(request);
response.RegisterActionFor2XXSuccess(successAction)
.ExecuteAction();
}
public async Task GetGenre(long id, Action<GenreResponse>? successAction = null, Action? notFoundAction = null)
{
string url = GetUrl(EndpointsConfiguration.Genres.GetGenre, id);
HttpRequest request = new HttpRequest(HttpMethodType.Get, url);
HttpResponse response = await _httpClientService.SendRequestAsync(request);
response.RegisterActionFor2XXSuccess(successAction)
.RegisterActionFor404NotFound(notFoundAction)
.ExecuteAction();
}
public async Task PostGenre(GenreRequest data, Action<GenreResponse>? successAction = null, Action<IDictionary<string, string[]>>? badRequestAction = null, Action? unauthorizedAction = null, Action? forbiddenAction = null)
{
string url = GetUrl(EndpointsConfiguration.Genres.PostGenre);
HttpRequest request = new HttpRequest(HttpMethodType.Post, url);
request.Body = data;
HttpResponse response = await _httpClientService.SendRequestAsync(request);
response.RegisterActionFor2XXSuccess(successAction)
.RegisterActionFor400BadRequest(badRequestAction)
.RegisterActionFor401Unauthorized(unauthorizedAction)
.RegisterActionFor403Forbidden(forbiddenAction)
.ExecuteAction();
}
public async Task DeleteGenre(long id, Action? successAction = null, Action? unauthorizedAction = null, Action? forbiddenAction = null)
{
string url = GetUrl(EndpointsConfiguration.Genres.DeleteGenre, id);
HttpRequest request = new HttpRequest(HttpMethodType.Delete, url);
HttpResponse response = await _httpClientService.SendRequestAsync(request);
response.RegisterActionFor2XXSuccess(successAction)
.RegisterActionFor401Unauthorized(unauthorizedAction)
.RegisterActionFor403Forbidden(forbiddenAction)
.ExecuteAction();
}
#endregion
#region Media
public async Task GetGenreMedia(short id, MediaQueryParameters? query = null, Action<IEnumerable<MediaResponse>>? successAction = null, Action notFoundAction = null)
{
string url = GetUrl(EndpointsConfiguration.Genres.GetGenreMedia, id);
HttpRequest request = new HttpRequest(HttpMethodType.Get, url);
request.Query = query;
HttpResponse response = await _httpClientService.SendRequestAsync(request);
response.RegisterActionFor2XXSuccess(successAction)
.RegisterActionFor404NotFound(notFoundAction)
.ExecuteAction();
}
#endregion
#endregion
#region PRIVATE METHODS
protected override string GetServiceBase() => EndpointsConfiguration.Genres.Base;
#endregion
}

View File

@@ -0,0 +1,13 @@
using WatchIt.Common.Model.Genres;
using WatchIt.Common.Model.Media;
namespace WatchIt.Website.Services.Client.Genres;
public interface IGenresClientService
{
Task GetGenres(GenreQueryParameters? query = null, Action<IEnumerable<GenreResponse>>? successAction = null);
Task GetGenre(long id, Action<GenreResponse>? successAction = null, Action? notFoundAction = null);
Task PostGenre(GenreRequest data, Action<GenreResponse>? successAction = null, Action<IDictionary<string, string[]>>? badRequestAction = null, Action? unauthorizedAction = null, Action? forbiddenAction = null);
Task DeleteGenre(long id, Action? successAction = null, Action? unauthorizedAction = null, Action? forbiddenAction = null);
Task GetGenreMedia(short id, MediaQueryParameters? query = null, Action<IEnumerable<MediaResponse>>? successAction = null, Action notFoundAction = null);
}

View File

@@ -3,9 +3,10 @@
public class Genres
{
public string Base { get; set; }
public string GetAll { get; set; }
public string Get { get; set; }
public string Post { get; set; }
public string Put { get; set; }
public string Delete { get; set; }
public string GetGenres { get; set; }
public string GetGenre { get; set; }
public string PostGenre { get; set; }
public string PutGenre { get; set; }
public string DeleteGenre { get; set; }
public string GetGenreMedia { get; set; }
}

View File

@@ -36,6 +36,7 @@ public partial class ListComponent<TItem, TQuery> : ComponentBase where TItem :
[Parameter] public Func<long, RatingRequest, Task>? PutRatingMethod { get; set; }
[Parameter] public Func<long, Task>? DeleteRatingMethod { get; set; }
[Parameter] public required string PosterPlaceholder { get; set; }
[Parameter] public TQuery Query { get; set; } = Activator.CreateInstance<TQuery>()!;
#endregion
@@ -54,14 +55,6 @@ public partial class ListComponent<TItem, TQuery> : ComponentBase where TItem :
#region PROPERTIES
public TQuery Query { get; set; } = Activator.CreateInstance<TQuery>()!;
#endregion
#region PRIVATE METHODS
protected override async Task OnAfterRenderAsync(bool firstRender)

View File

@@ -0,0 +1,68 @@
@inherits WatchIt.Website.Components.Common.ListComponent.FilterFormComponent<WatchIt.Common.Model.Media.MediaResponse, WatchIt.Common.Model.Media.MediaQueryParameters>
<EditForm Model="@(Query)">
<div class="container-grid">
<div class="row mb-1">
<div class="input-group input-group-sm">
<span class="col-3 input-group-text">Type</span>
<InputSelect TValue="MediaType?" class="col form-control" @bind-Value="@(Query.Type)">
<option @onclick="() => Query.Type = null">No choice</option>
<option value="@(MediaType.Movie)">Movies</option>
<option value="@(MediaType.Series)">TV series</option>
</InputSelect>
</div>
</div>
<div class="row mb-1">
<div class="input-group input-group-sm">
<span class="col-3 input-group-text">Title</span>
<InputText class="col form-control" placeholder="Search with regex" @bind-Value="@(Query.Title)"></InputText>
</div>
</div>
<div class="row my-1">
<div class="input-group input-group-sm">
<span class="col-3 input-group-text">Original title</span>
<InputText class="col form-control" placeholder="Search with regex" @bind-Value="@(Query.OriginalTitle)"/>
</div>
</div>
<div class="row my-1">
<div class="input-group input-group-sm">
<span class="col-3 input-group-text">Description</span>
<InputText class="col form-control" placeholder="Search with regex" @bind-Value="@(Query.Description)"/>
</div>
</div>
<div class="row my-1">
<div class="input-group input-group-sm">
<span class="col-3 input-group-text">Release date</span>
<InputDate TValue="DateOnly?" class="col form-control" @bind-Value="@(Query.ReleaseDateFrom)"/>
<span class="col-auto input-group-text">-</span>
<InputDate TValue="DateOnly?" class="col form-control" @bind-Value="@(Query.ReleaseDateTo)"/>
</div>
</div>
<div class="row my-1">
<div class="input-group input-group-sm">
<span class="col-3 input-group-text">Length</span>
<NumericEdit TValue="short?" Class="col form-control" Min="0" @bind-Value="@(Query.LengthFrom)"/>
<span class="col-auto input-group-text">-</span>
<NumericEdit TValue="short?" Class="col form-control" Min="0" @bind-Value="@(Query.LengthTo)"/>
</div>
</div>
<div class="row my-1">
<div class="input-group input-group-sm">
<span class="col-3 input-group-text">Rating (count)</span>
<NumericEdit TValue="long?" Class="col form-control" Min="0" @bind-Value="@(Query.RatingCountFrom)"/>
<span class="col-auto input-group-text">-</span>
<NumericEdit TValue="long?" Class="col form-control" Min="0" @bind-Value="@(Query.RatingCountTo)"/>
</div>
</div>
<div class="row mt-1">
<div class="input-group input-group-sm">
<span class="col-3 input-group-text">Rating (average)</span>
<NumericEdit TValue="decimal?" Class="col form-control" Min="0" Max="10" Step="@(0.01M)" @bind-Value="@(Query.RatingAverageFrom)"/>
<span class="col-auto input-group-text">-</span>
<NumericEdit TValue="decimal?" Class="col form-control" Min="0" Max="10" Step="@(0.01M)" @bind-Value="@(Query.RatingAverageTo)"/>
</div>
</div>
</div>
</EditForm>

View File

@@ -0,0 +1,18 @@
<div class="panel" role="button" @onclick="@(MediaData is not null ? () => NavigationManager.NavigateTo($"/media/{MediaData.Id}") : null)" style="cursor: @(MediaData is null ? "default" : "pointer")">
<div class="d-flex gap-3 align-items-center">
<PictureComponent Picture="_poster" Height="60" Placeholder="/assets/media_poster.png" AlternativeText="poster"/>
<div class="d-flex-inline flex-column">
<h2 id="primaryText" class="m-0">
@if (MediaData is null)
{
<span class="fw-bold">New @(MediaType)</span>
}
else
{
<span><span class="fw-bold">@(MediaData.Title)</span>@(MediaData.ReleaseDate.HasValue ? $" ({MediaData.ReleaseDate.Value.Year})" : string.Empty)</span>
}
</h2>
<span id="secondaryText" class="text-secondary">Media settings</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Components;
using WatchIt.Common.Model.Media;
using WatchIt.Website.Services.Client.Media;
namespace WatchIt.Website.Components.Pages.MediaEditPage.Panels;
public partial class MediaEditPageHeaderPanelComponent : ComponentBase
{
#region SERVICES
[Inject] public IMediaClientService MediaClientService { get; set; } = default!;
[Inject] public NavigationManager NavigationManager { get; set; } = default!;
#endregion
#region PARAMETERS
[Parameter] public required MediaResponse? MediaData { get; set; }
[Parameter] public required string MediaType { get; set; }
#endregion
#region FIELDS
private MediaPosterResponse? _poster;
private List<KeyValuePair<string, object>> _attr = new List<KeyValuePair<string, object>>();
#endregion
#region PRIVATE METHODS
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (MediaData is not null)
{
await MediaClientService.GetMediaPoster(MediaData.Id, data => _poster = data);
_attr.Add(new KeyValuePair<string, object>("@onclick", () => NavigationManager.NavigateTo($"/media/{MediaData.Id}")));
StateHasChanged();
}
}
}
#endregion
}

View File

@@ -0,0 +1,9 @@
/* IDS */
#primaryText {
margin-top: -8px !important;
}
#secondaryText {
color: lightgray !important;
}

View File

@@ -0,0 +1,47 @@
@using Blazorise.Extensions
@using WatchIt.Common.Model.Genres
<div class="panel">
<div class="vstack gap-3">
<h4 class="fw-bold">Genres</h4>
<div class="d-flex gap-3">
<InputSelect class="w-100 form-control" TValue="short?" @bind-Value="@(_selectedGenre)">
<option value="@(default(short?))" selected hidden="hidden">Choose genre...</option>
@foreach (GenreResponse genre in _availableGenres)
{
<option value="@(genre.Id)">@(genre.Name)</option>
}
</InputSelect>
<button class="btn btn-secondary" @onclick="AddGenre" disabled="@(_selectedGenre is null || _addLoading || _chosenGenres.Values.Any(x => x))">
<LoadingButtonContentComponent Content="Add" LoadingContent="Adding..." IsLoading="@(_addLoading)"/>
</button>
</div>
@if (_chosenGenres.IsNullOrEmpty())
{
<span class="text-center">No items</span>
}
else
{
<table class="table table-sm table-transparent">
<tbody>
@foreach (KeyValuePair<GenreResponse, bool> genre in _chosenGenres)
{
<tr>
<td class="align-middle">
@(genre.Key.Name)
</td>
<td class="align-middle table-cell-fit">
<button class="btn btn-outline-danger btn-sm w-100" type="button" disabled="@(_addLoading || genre.Value)" @onclick="@(() => RemoveGenre(genre.Key))">
<LoadingButtonContentComponent IsLoading="@(genre.Value)">
<i class="fa-solid fa-trash"></i>
</LoadingButtonContentComponent>
</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>

View File

@@ -0,0 +1,83 @@
using Microsoft.AspNetCore.Components;
using WatchIt.Common.Model.Genres;
using WatchIt.Common.Model.Media;
using WatchIt.Website.Services.Client.Genres;
using WatchIt.Website.Services.Client.Media;
namespace WatchIt.Website.Components.Pages.MediaEditPage.Panels;
public partial class MediaGenresEditPanelComponent : ComponentBase
{
#region SERVICES
[Inject] private IGenresClientService GenresClientService { get; set; } = default!;
[Inject] private IMediaClientService MediaClientService { get; set; } = default!;
#endregion
#region PARAMETERS
[Parameter] public required MediaResponse Data { get; set; }
#endregion
#region FIELDS
private Dictionary<GenreResponse, bool> _chosenGenres = new Dictionary<GenreResponse, bool>();
private List<GenreResponse> _availableGenres = new List<GenreResponse>();
private short? _selectedGenre;
private bool _addLoading;
#endregion
#region PRIVATE METHODS
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
foreach (GenreResponse genre in Data.Genres)
{
_chosenGenres[genre] = false;
}
await GenresClientService.GetGenres(successAction: data =>
{
IEnumerable<short> tempSelected = _chosenGenres.Keys.Select(x => x.Id);
_availableGenres.AddRange(data.Where(x => !tempSelected.Contains(x.Id)));
});
StateHasChanged();
}
}
private async Task AddGenre()
{
_addLoading = true;
await MediaClientService.PostMediaGenre(Data.Id, _selectedGenre!.Value, () =>
{
GenreResponse selectedGenre = _availableGenres.First(x => x.Id == _selectedGenre);
_availableGenres.Remove(selectedGenre);
_chosenGenres[selectedGenre] = false;
_addLoading = false;
_selectedGenre = null;
});
}
private async Task RemoveGenre(GenreResponse genre)
{
_chosenGenres[genre] = true;
await MediaClientService.DeleteMediaGenre(Data.Id, genre.Id, () =>
{
_chosenGenres.Remove(genre);
_availableGenres.Add(genre);
});
}
#endregion
}

View File

@@ -2,7 +2,7 @@
<div class="d-flex gap-3 align-items-center">
<AccountPictureComponent @ref="_accountPicture" Id="@(AccountData.Id)" Size="60"/>
<div class="d-flex-inline flex-column">
<h2 id="username" class="fw-bold m-0">@(AccountData.Username)</h2>
<h2 id="primaryText" class="fw-bold m-0">@(AccountData.Username)</h2>
<span id="secondaryText" class="text-secondary">User settings</span>
</div>
</div>

View File

@@ -1,6 +1,6 @@
/* IDS */
#username {
#primaryText {
margin-top: -8px !important;
}

View File

@@ -0,0 +1,54 @@
@using System.Text
@using WatchIt.Website.Components.Common.ListComponent
@page "/genre/{id:int}"
@{
StringBuilder sb = new StringBuilder(" - WatchIt");
if (!_loaded) sb.Insert(0, "Loading...");
else if (_data is null) sb.Insert(0, "Error");
else sb.Insert(0, $"\"{_data.Name}\" genre");
<PageTitle>@(sb.ToString())</PageTitle>
}
@if (!_loaded)
{
<div class="m-5">
<LoadingComponent/>
</div>
}
else if (_data is null)
{
<ErrorPanelComponent ErrorMessage="@($"Genre with ID {Id} was not found")"/>
}
else
{
<ListComponent TItem="MediaResponse"
TQuery="MediaQueryParameters"
Title="@(_data.Name)"
IdSource="@(item => item.Id)"
NameSource="@(item => item.Title)"
AdditionalNameInfoSource="@(item => item.ReleaseDate.HasValue ? $" ({item.ReleaseDate.Value.Year})" : null)"
RatingSource="@(item => item.Rating)"
UrlIdTemplate="/media/{0}"
PictureDownloadingTask="@((id, action) => MediaClientService.GetMediaPoster(id, action))"
ItemDownloadingTask="@((query, action) => GenresClientService.GetGenreMedia(_data.Id, query, action))"
SortingOptions="@(new Dictionary<string, string>
{
{ "rating.count", "Number of ratings" },
{ "rating.average", "Average rating" },
{ "title", "Title" },
{ "release_date", "Release date" },
})"
PosterPlaceholder="/assets/media_poster.png"
GetGlobalRatingMethod="@((id, action) => MediaClientService.GetMediaRating(id, action))"
GetUserRatingMethod="@((id, userId, successAction, notfoundAction) => MediaClientService.GetMediaRatingByUser(id, userId, successAction, notfoundAction))"
PutRatingMethod="@((id, request) => MediaClientService.PutMediaRating(id, request))"
DeleteRatingMethod="@(id => MediaClientService.DeleteMediaRating(id))">
<MediaFilterFormComponent/>
</ListComponent>
}

View File

@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Components;
using WatchIt.Common.Model.Genres;
using WatchIt.Website.Layout;
using WatchIt.Website.Services.Client.Genres;
using WatchIt.Website.Services.Client.Media;
namespace WatchIt.Website.Pages;
public partial class GenrePage : ComponentBase
{
#region SERVICES
[Inject] private IGenresClientService GenresClientService { get; set; } = default!;
[Inject] private IMediaClientService MediaClientService { get; set; } = default!;
#endregion
#region PARAMETERS
[Parameter] public int Id { get; set; }
[CascadingParameter] public MainLayout Layout { get; set; }
#endregion
#region FIELDS
private bool _loaded;
private GenreResponse? _data;
#endregion
#region PRIVATE METHODS
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
Layout.BackgroundPhoto = null;
await GenresClientService.GetGenre(Id, data => _data = data);
_loaded = true;
StateHasChanged();
}
}
#endregion
}

View File

@@ -1,4 +1,5 @@
@using Microsoft.IdentityModel.Tokens
@using System.Text
@using Microsoft.IdentityModel.Tokens
@using WatchIt.Common.Model.Movies
@using WatchIt.Common.Model.Photos
@using WatchIt.Common.Model.Series
@@ -7,361 +8,319 @@
@page "/media/{id:long}/edit"
@page "/media/new/{type?}"
@{
StringBuilder sb = new StringBuilder(" - WatchIt");
<PageTitle>
WatchIt -
@if (_loaded)
if (!_loaded) sb.Insert(0, "Loading...");
else if (!string.IsNullOrWhiteSpace(_error) || _user?.IsAdmin != true) sb.Insert(0, "Error");
else
{
if (string.IsNullOrWhiteSpace(_error) && _user?.IsAdmin == true)
{
if (_media is not null)
{
@($"Edit \"")@(_media.Title)@("\"")
}
else
{
if (_movieRequest is null)
{
@("New TV series")
}
else
{
@("New movie")
}
}
}
if (_media is not null) sb.Insert(0, $"Edit \"{_media.Title}\"");
else
{
@("Error")
if (_movieRequest is null) sb.Insert(0, "TV series");
else sb.Insert(0, "movie");
sb.Insert(0, "New ");
}
}
<PageTitle>@(sb.ToString())</PageTitle>
}
<div class="vstack gap-default">
@if (!_loaded)
{
<div class="m-5">
<LoadingComponent/>
</div>
}
else if (!string.IsNullOrWhiteSpace(_error))
{
<ErrorPanelComponent ErrorMessage="@_error"/>
}
else if (_user?.IsAdmin != true)
{
<ErrorPanelComponent ErrorMessage="You do not have permission to view this site"/>
}
else
{
@("Loading")
}
</PageTitle>
<div class="container-grid">
@if (_loaded)
{
if (string.IsNullOrWhiteSpace(_error))
{
if (_user?.IsAdmin == true)
{
<div class="row">
<div class="col">
<div class="rounded-3 panel panel-regular p-2">
<div class="m-0 mx-2 mb-1 p-0">
@if (_media is not null)
{
<a class="text-decoration-none text-reset" href="/media/@(_media.Id)">
<h3>Edit @(_movieRequest is not null ? "movie" : "series") "@(_media.Title)"</h3>
</a>
}
else
{
<h3>Create new @(_movieRequest is not null ? "movie" : "series")</h3>
}
<MediaEditPageHeaderPanelComponent MediaData="@(_media)"
MediaType="@(_movieRequest is null ? "TV series" : "movie")"/>
<div class="d-flex align-items-stretch gap-3">
<PictureEditorPanelComponent Id="@(Id)"
PictureGetTask="@(async (id, action) => await MediaClientService.GetMediaPoster(id, action))"
PicturePutTask="@(async (id, data, action) => await MediaClientService.PutMediaPoster(id, new MediaPosterRequest(data), action))"
PictureDeleteTask="@(async (id, action) => await MediaClientService.DeleteMediaPoster(id, action))"
PicturePlaceholder="/assets/media_poster.png"/>
<div class="rounded-3 panel panel-regular p-4 w-100">
<EditForm Model="_mediaRequest">
<AntiforgeryToken/>
<div class="container-grid">
<div class="row form-group mb-1">
<label for="title" class="col-2 col-form-label">Title*</label>
<div class="col-10">
<InputText id="title" class="form-control" @bind-Value="_mediaRequest!.Title"/>
</div>
</div>
<div class="row form-group my-1">
<label for="og-title" class="col-2 col-form-label">Original title</label>
<div class="col-10">
<InputText id="og-title" class="form-control" @bind-Value="_mediaRequest!.OriginalTitle"/>
</div>
</div>
<div class="row form-group my-1">
<label for="desc" class="col-2 col-form-label">Description</label>
<div class="col-10">
<InputTextArea id="desc" class="form-control" @bind-Value="_mediaRequest!.Description"/>
</div>
</div>
<div class="row form-group my-1">
<label for="rel-date" class="col-2 col-form-label">Release date</label>
<div class="col-4">
<InputDate TValue="DateOnly?" id="rel-date" class="form-control" @bind-Value="_mediaRequest!.ReleaseDate"/>
</div>
<label for="length" class="col-2 col-form-label">Length</label>
<div class="col-4">
<InputNumber TValue="short?" id="length" class="form-control" @bind-Value="_mediaRequest!.Length"/>
</div>
</div>
@if (_mediaRequest is MovieRequest)
{
<div class="row form-group mt-1">
<label for="budget" class="col-2 col-form-label">Budget</label>
<div class="col-10">
<InputNumber TValue="decimal?" id="budget" class="form-control" @bind-Value="((MovieRequest)_mediaRequest)!.Budget"/>
</div>
</div>
}
else
{
<div class="row form-group mt-1">
<label class="col-2 col-form-label">Has ended</label>
<div class="col-10 col-form-label">
<div class="d-flex gap-3">
<InputRadioGroup TValue="bool" @bind-Value="((SeriesRequest)_mediaRequest)!.HasEnded">
<div class="d-flex gap-2">
<InputRadio TValue="bool" Value="true"/>
Yes
</div>
<div class="d-flex gap-2">
<InputRadio TValue="bool" Value="false"/>
No
</div>
</InputRadioGroup>
</div>
</div>
</div>
}
<div class="row mt-4">
<div class="col">
<div class="d-flex justify-content-end align-items-center gap-3">
@if (!string.IsNullOrWhiteSpace(_basicDataError))
{
<div class="text-danger">@_basicDataError</div>
}
<button type="button" class="btn btn-secondary" @onclick="SaveBasicData">
@if (!_basicDataSaving)
{
<span>Save</span>
}
else
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Saving...</span>
}
</button>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-3 gx-3">
<div class="col-auto">
<PictureEditorPanelComponent Id="@(Id)"
PictureGetTask="@(async (id, action) => await MediaClientService.GetMediaPoster(id, action))"
PicturePutTask="@(async (id, data, action) => await MediaClientService.PutMediaPoster(id, new MediaPosterRequest(data), action))"
PictureDeleteTask="@(async (id, action) => await MediaClientService.DeleteMediaPoster(id, action))"
PicturePlaceholder="/assets/media_poster.png"
Class="h-100"/>
</div>
<div class="col">
<div class="rounded-3 panel panel-regular p-4 h-100">
<EditForm Model="_mediaRequest">
<AntiforgeryToken/>
<div class="container-grid">
<div class="row form-group mb-1">
<label for="title" class="col-2 col-form-label">Title*</label>
<div class="col-10">
<InputText id="title" class="form-control" @bind-Value="_mediaRequest!.Title"/>
</div>
</EditForm>
</div>
</div>
<Tabs Pills
RenderMode="TabsRenderMode.LazyLoad"
Class="panel panel-menu panel-background-menu justify-content-center"
SelectedTab="genres">
<Items>
<Tab Name="genres">Genres</Tab>
<Tab Name="actors">Actors</Tab>
<Tab Name="creators">Creators</Tab>
<Tab Name="photos">Photos</Tab>
</Items>
<Content>
<TabPanel Name="genres">
<MediaGenresEditPanelComponent Data="@(_media)"/>
</TabPanel>
<TabPanel Name="actors">
<MediaActorRolesEditPanelComponent Id="@(Id)"
Persons="@(_persons)"/>
</TabPanel>
<TabPanel Name="creators">
<MediaCreatorRolesEditPanelComponent Id="@(Id)"
Persons="@(_persons)"/>
</TabPanel>
<TabPanel Name="photos">
<div class="rounded-3 panel panel-regular p-4">
<div class="container-grid">
<div class="row mb-3">
<div class="col">
<div class="d-flex align-items-center h-100">
<h4 class="m-0"><strong>Photos</strong></h4>
</div>
<div class="row form-group my-1">
<label for="og-title" class="col-2 col-form-label">Original title</label>
<div class="col-10">
<InputText id="og-title" class="form-control" @bind-Value="_mediaRequest!.OriginalTitle"/>
</div>
</div>
<div class="row form-group my-1">
<label for="desc" class="col-2 col-form-label">Description</label>
<div class="col-10">
<InputTextArea id="desc" class="form-control" @bind-Value="_mediaRequest!.Description"/>
</div>
</div>
<div class="row form-group my-1">
<label for="rel-date" class="col-2 col-form-label">Release date</label>
<div class="col-4">
<InputDate TValue="DateOnly?" id="rel-date" class="form-control" @bind-Value="_mediaRequest!.ReleaseDate"/>
</div>
<label for="length" class="col-2 col-form-label">Length</label>
<div class="col-4">
<InputNumber TValue="short?" id="length" class="form-control" @bind-Value="_mediaRequest!.Length"/>
</div>
</div>
@if (_mediaRequest is MovieRequest)
</div>
<div class="col-auto">
@if (!_photoEditMode)
{
<div class="row form-group mt-1">
<label for="budget" class="col-2 col-form-label">Budget</label>
<div class="col-10">
<InputNumber TValue="decimal?" id="budget" class="form-control" @bind-Value="((MovieRequest)_mediaRequest)!.Budget"/>
</div>
</div>
<button type="button" class="btn btn-secondary" disabled="@(!Id.HasValue)" @onclick="() => InitEditPhoto(null)">Add new photo</button>
}
else
{
<div class="row form-group mt-1">
<label class="col-2 col-form-label">Has ended</label>
<div class="col-10 col-form-label">
<div class="d-flex gap-3">
<InputRadioGroup TValue="bool" @bind-Value="((SeriesRequest)_mediaRequest)!.HasEnded">
<div class="d-flex gap-2">
<InputRadio TValue="bool" Value="true"/>
Yes
</div>
<div class="d-flex gap-2">
<InputRadio TValue="bool" Value="false"/>
No
</div>
</InputRadioGroup>
</div>
</div>
</div>
}
<div class="row mt-4">
<div class="col">
<div class="d-flex justify-content-end align-items-center gap-3">
@if (!string.IsNullOrWhiteSpace(_basicDataError))
{
<div class="text-danger">@_basicDataError</div>
}
<button type="button" class="btn btn-secondary" @onclick="SaveBasicData">
@if (!_basicDataSaving)
{
<span>Save</span>
}
else
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Saving...</span>
}
</button>
</div>
</div>
</div>
</div>
</EditForm>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col">
<MediaActorRolesEditPanelComponent Id="@(Id)"
Persons="@(_persons)"/>
</div>
</div>
<div class="row mt-3">
<div class="col">
<MediaCreatorRolesEditPanelComponent Id="@(Id)"
Persons="@(_persons)"/>
</div>
</div>
<div class="row mt-3">
<div class="col">
<div class="rounded-3 panel panel-regular p-4">
<div class="container-grid">
<div class="row mb-3">
<div class="col">
<div class="d-flex align-items-center h-100">
<h4 class="m-0"><strong>Photos</strong></h4>
</div>
</div>
<div class="col-auto">
@if (!_photoEditMode)
<div class="d-flex gap-3 align-items-center">
@if (!string.IsNullOrWhiteSpace(_photoEditError))
{
<button type="button" class="btn btn-secondary" disabled="@(!Id.HasValue)" @onclick="() => InitEditPhoto(null)">Add new photo</button>
<div class="text-danger">
@_photoEditError
</div>
}
else
{
<div class="d-flex gap-3 align-items-center">
@if (!string.IsNullOrWhiteSpace(_photoEditError))
{
<div class="text-danger">
@_photoEditError
</div>
}
<button type="button" class="btn btn-secondary" disabled="@(_photoEditSaving)" @onclick="SaveEditPhoto">
@if (!_photoEditSaving)
{
<span>Save</span>
}
else
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Saving...</span>
}
</button>
<button type="button" class="btn btn-secondary" disabled="@(_photoEditSaving)" @onclick="CancelEditPhoto">Cancel</button>
</div>
}
</div>
</div>
<div class="row">
<div class="col">
@if (!_photoEditMode)
{
if (!_photos.IsNullOrEmpty())
<button type="button" class="btn btn-secondary" disabled="@(_photoEditSaving)" @onclick="SaveEditPhoto">
@if (!_photoEditSaving)
{
<div id="scrollPhotos" class="d-flex p-3 gap-3" data-bs-spy="scroll" tabindex="0">
@foreach (PhotoResponse photo in _photos)
{
<div class="container-grid photo-container">
<div class="row">
<div class="col">
<PictureComponent Picture="@(photo)" AlternativeText="photo" Width="350" Placeholder="/assets/photo.png" AspectRatio="PictureComponent.PictureComponentAspectRatio.Photo"/>
</div>
</div>
<div class="row mt-2 gx-2">
@if (photo.Background is not null)
{
<div class="col-auto">
<div class="d-flex align-items-center">
<div id="backgroundIndicator" class="border rounded-circle circle-@(photo.Background.IsUniversalBackground ? "blue" : "grey") p-1" data-toggle="tooltip" data-placement="top" title="@(photo.Background.IsUniversalBackground ? "Universal" : "Media-only") background">
<img class="no-vertical-align" src="assets/icons/background.png" alt="background_icon" height="20px" width="20px"/>
</div>
</div>
</div>
}
<div class="col">
<div class="d-flex align-items-center h-100 text-size-upload-date">
Upload: @(photo.UploadDate.ToString())
</div>
</div>
<div class="col-auto">
<button type="button" class="btn btn-secondary btn-sm" @onclick="() => InitEditPhoto(photo.Id)" disabled="@(_photoDeleting.Contains(photo.Id))">
<img src="assets/icons/edit.png" alt="edit_icon" height="20px" width="20px"/>
</button>
</div>
<div class="col-auto">
<button type="button" class="btn btn-danger btn-sm" disabled="@(_photoDeleting.Contains(photo.Id))" @onclick="() => DeletePhoto(photo.Id)">
@if (!_photoDeleting.Contains(photo.Id))
{
<img src="assets/icons/delete.png" alt="delete_icon" height="20px" width="20px"/>
}
else
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
}
</button>
</div>
</div>
</div>
}
</div>
<span>Save</span>
}
else
{
<div class="d-flex justify-content-center">
Photo list is empty
</div>
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Saving...</span>
}
}
else
</button>
<button type="button" class="btn btn-secondary" disabled="@(_photoEditSaving)" @onclick="CancelEditPhoto">Cancel</button>
</div>
}
</div>
</div>
<div class="row">
<div class="col">
@if (!_photoEditMode)
{
if (!_photos.IsNullOrEmpty())
{
<div id="scrollPhotos" class="d-flex p-3 gap-3" data-bs-spy="scroll" tabindex="0">
@foreach (PhotoResponse photo in _photos)
{
<div class="container-grid">
<div class="row">
<div class="col-auto">
<div class="container-grid">
<div class="row">
<div class="col">
<PictureComponent Picture="@(_photoEditRequest)" Placeholder="/assets/photo.png" AlternativeText="edit_photo" Width="300" AspectRatio="PictureComponent.PictureComponentAspectRatio.Photo"/>
</div>
</div>
@if (_photoEditId is null)
{
<div class="row mt-2">
<div class="col">
<InputFile class="form-control" OnChange="LoadPhoto" autocomplete="off" style="width: 300px;"/>
</div>
</div>
}
<div class="container-grid photo-container">
<div class="row">
<div class="col">
<PictureComponent Picture="@(photo)" AlternativeText="photo" Width="350" Placeholder="/assets/photo.png" AspectRatio="PictureComponent.PictureComponentAspectRatio.Photo"/>
</div>
</div>
<div class="row mt-2 gx-2">
@if (photo.Background is not null)
{
<div class="col-auto">
<div class="d-flex align-items-center">
<div id="backgroundIndicator" class="border rounded-circle circle-@(photo.Background.IsUniversalBackground ? "blue" : "grey") p-1" data-toggle="tooltip" data-placement="top" title="@(photo.Background.IsUniversalBackground ? "Universal" : "Media-only") background">
<img class="no-vertical-align" src="assets/icons/background.png" alt="background_icon" height="20px" width="20px"/>
</div>
</div>
<div class="col">
<div class="container-grid">
<div class="row form-group">
<div class="col">
<div class="form-check">
<InputCheckbox class="form-check-input" @bind-Value="_photoEditIsBackground"/>
<label class="form-check-label">Use as background</label>
</div>
</div>
<div class="col">
<div class="form-check">
<InputCheckbox class="form-check-input" @bind-Value="_photoEditBackgroundData.IsUniversalBackground" disabled="@(!_photoEditIsBackground)"/>
<label class="form-check-label">Use as universal background</label>
</div>
</div>
</div>
}
<div class="col">
<div class="d-flex align-items-center h-100 text-size-upload-date">
Upload: @(photo.UploadDate.ToString())
</div>
</div>
<div class="col-auto">
<button type="button" class="btn btn-secondary btn-sm" @onclick="() => InitEditPhoto(photo.Id)" disabled="@(_photoDeleting.Contains(photo.Id))">
<img src="assets/icons/edit.png" alt="edit_icon" height="20px" width="20px"/>
</button>
</div>
<div class="col-auto">
<button type="button" class="btn btn-danger btn-sm" disabled="@(_photoDeleting.Contains(photo.Id))" @onclick="() => DeletePhoto(photo.Id)">
@if (!_photoDeleting.Contains(photo.Id))
{
<img src="assets/icons/delete.png" alt="delete_icon" height="20px" width="20px"/>
}
else
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
}
</button>
</div>
</div>
</div>
}
</div>
}
else
{
<div class="d-flex justify-content-center">
Photo list is empty
</div>
}
}
else
{
<div class="container-grid">
<div class="row">
<div class="col-auto">
<div class="container-grid">
<div class="row">
<div class="col">
<PictureComponent Picture="@(_photoEditRequest)" Placeholder="/assets/photo.png" AlternativeText="edit_photo" Width="300" AspectRatio="PictureComponent.PictureComponentAspectRatio.Photo"/>
</div>
</div>
@if (_photoEditId is null)
{
<div class="row mt-2">
<div class="col">
<InputFile class="form-control" OnChange="LoadPhoto" autocomplete="off" style="width: 300px;"/>
</div>
</div>
}
</div>
</div>
<div class="col">
<div class="container-grid">
<div class="row form-group">
<div class="col">
<div class="form-check">
<InputCheckbox class="form-check-input" @bind-Value="_photoEditIsBackground"/>
<label class="form-check-label">Use as background</label>
</div>
<div class="row form-group my-1">
<label for="first-gradient-color" class="col-4 col-form-label">First gradient color</label>
<div class="col-8">
<input type="color" class="form-control form-control-color w-100" id="first-gradient-color" value="#@(Convert.ToHexString(_photoEditBackgroundData.FirstGradientColor))" disabled="@(!_photoEditIsBackground)" @onchange="EditPhotoFirstGradientColorChanged">
</div>
</div>
<div class="row form-group">
<label for="second-gradient-color" class="col-4 col-form-label">Second gradient color</label>
<div class="col-8">
<input type="color" class="form-control form-control-color w-100" id="second-gradient-color" value="#@(Convert.ToHexString(_photoEditBackgroundData.SecondGradientColor))" disabled="@(!_photoEditIsBackground)" @onchange="EditPhotoSecondGradientColorChanged">
</div>
</div>
<div class="col">
<div class="form-check">
<InputCheckbox class="form-check-input" @bind-Value="_photoEditBackgroundData.IsUniversalBackground" disabled="@(!_photoEditIsBackground)"/>
<label class="form-check-label">Use as universal background</label>
</div>
</div>
</div>
<div class="row form-group my-1">
<label for="first-gradient-color" class="col-4 col-form-label">First gradient color</label>
<div class="col-8">
<input type="color" class="form-control form-control-color w-100" id="first-gradient-color" value="#@(Convert.ToHexString(_photoEditBackgroundData.FirstGradientColor))" disabled="@(!_photoEditIsBackground)" @onchange="EditPhotoFirstGradientColorChanged">
</div>
</div>
<div class="row form-group">
<label for="second-gradient-color" class="col-4 col-form-label">Second gradient color</label>
<div class="col-8">
<input type="color" class="form-control form-control-color w-100" id="second-gradient-color" value="#@(Convert.ToHexString(_photoEditBackgroundData.SecondGradientColor))" disabled="@(!_photoEditIsBackground)" @onchange="EditPhotoSecondGradientColorChanged">
</div>
</div>
</div>
</div>
}
</div>
</div>
}
</div>
</div>
</div>
</div>
</div>
}
else
{
<div class="row">
<div class="col">
<ErrorPanelComponent ErrorMessage="You do not have permission to view this site"/>
</div>
</div>
}
}
else
{
<div class="row">
<div class="col">
<ErrorPanelComponent ErrorMessage="@_error"/>
</div>
</div>
}
}
else
{
<div class="row">
<div class="col">
<div class="m-5">
<LoadingComponent/>
</div>
</div>
</div>
</TabPanel>
</Content>
</Tabs>
}
</div>

View File

@@ -10,6 +10,7 @@ using WatchIt.Website.Services.Configuration;
using WatchIt.Website.Services.Tokens;
using WatchIt.Website.Services.Client.Accounts;
using WatchIt.Website.Services.Client.Genders;
using WatchIt.Website.Services.Client.Genres;
using WatchIt.Website.Services.Client.Media;
using WatchIt.Website.Services.Client.Movies;
using WatchIt.Website.Services.Client.Persons;
@@ -75,6 +76,7 @@ public static class Program
// WebAPI
builder.Services.AddScoped<IAccountsClientService, AccountsClientService>();
builder.Services.AddSingleton<IGendersClientService, GendersClientService>();
builder.Services.AddSingleton<IGenresClientService, GenresClientService>();
builder.Services.AddSingleton<IMediaClientService, MediaClientService>();
builder.Services.AddSingleton<IMoviesClientService, MoviesClientService>();
builder.Services.AddSingleton<ISeriesClientService, SeriesClientService>();

View File

@@ -46,11 +46,12 @@
},
"Genres": {
"Base": "/genres",
"GetAll": "",
"Get": "/{0}",
"Post": "",
"Put": "/{0}",
"Delete": "/{0}"
"GetGenres": "",
"GetGenre": "/{0}",
"PostGenre": "",
"PutGenre": "/{0}",
"DeleteGenre": "/{0}",
"GetGenreMedia": "/{0}/media"
},
"Media": {
"Base": "/media",