Added collections with the ability to add games to a collection

main
Pat Hartl 2023-12-04 01:39:59 -06:00
parent c48d5b5d59
commit 920b6b26f7
13 changed files with 2592 additions and 2 deletions

View File

@ -20,6 +20,7 @@ namespace LANCommander.Data
builder.ConfigureBaseRelationships<Data.Models.Action>();
builder.ConfigureBaseRelationships<Archive>();
builder.ConfigureBaseRelationships<Category>();
builder.ConfigureBaseRelationships<Collection>();
builder.ConfigureBaseRelationships<Company>();
builder.ConfigureBaseRelationships<Game>();
builder.ConfigureBaseRelationships<GameSave>();
@ -180,6 +181,28 @@ namespace LANCommander.Data
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
#endregion
#region Collection Relationships
builder.Entity<Collection>()
.HasMany(c => c.Games)
.WithMany(g => g.Collections)
.UsingEntity<Dictionary<string, object>>(
"CollectionGame",
cg => cg.HasOne<Game>().WithMany().HasForeignKey("GameId"),
cg => cg.HasOne<Collection>().WithMany().HasForeignKey("CollectionId")
);
#endregion
#region Role Relationships
builder.Entity<Role>()
.HasMany(r => r.Collections)
.WithMany(c => c.Roles)
.UsingEntity<Dictionary<string, object>>(
"RoleCollection",
rc => rc.HasOne<Collection>().WithMany().HasForeignKey("CollectionId"),
rc => rc.HasOne<Role>().WithMany().HasForeignKey("RoleId")
);
#endregion
}
public DbSet<Game>? Games { get; set; }

View File

@ -39,5 +39,6 @@ namespace LANCommander.Data.Models
public string? ValidKeyRegex { get; set; }
public virtual ICollection<Key>? Keys { get; set; }
public virtual ICollection<Collection> Collections { get; set; }
}
}

View File

@ -6,5 +6,6 @@ namespace LANCommander.Data.Models
[Table("Roles")]
public class Role : IdentityRole<Guid>
{
public virtual ICollection<Collection> Collections { get; set; }
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,124 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LANCommander.Migrations
{
/// <inheritdoc />
public partial class AddCollections : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Collections",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
CreatedOn = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedById = table.Column<Guid>(type: "TEXT", nullable: true),
UpdatedOn = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedById = table.Column<Guid>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Collections", x => x.Id);
table.ForeignKey(
name: "FK_Collections_AspNetUsers_CreatedById",
column: x => x.CreatedById,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Collections_AspNetUsers_UpdatedById",
column: x => x.UpdatedById,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "CollectionGame",
columns: table => new
{
CollectionId = table.Column<Guid>(type: "TEXT", nullable: false),
GameId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CollectionGame", x => new { x.CollectionId, x.GameId });
table.ForeignKey(
name: "FK_CollectionGame_Collections_CollectionId",
column: x => x.CollectionId,
principalTable: "Collections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CollectionGame_Games_GameId",
column: x => x.GameId,
principalTable: "Games",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RoleCollection",
columns: table => new
{
CollectionId = table.Column<Guid>(type: "TEXT", nullable: false),
RoleId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RoleCollection", x => new { x.CollectionId, x.RoleId });
table.ForeignKey(
name: "FK_RoleCollection_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RoleCollection_Collections_CollectionId",
column: x => x.CollectionId,
principalTable: "Collections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CollectionGame_GameId",
table: "CollectionGame",
column: "GameId");
migrationBuilder.CreateIndex(
name: "IX_Collections_CreatedById",
table: "Collections",
column: "CreatedById");
migrationBuilder.CreateIndex(
name: "IX_Collections_UpdatedById",
table: "Collections",
column: "UpdatedById");
migrationBuilder.CreateIndex(
name: "IX_RoleCollection_RoleId",
table: "RoleCollection",
column: "RoleId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CollectionGame");
migrationBuilder.DropTable(
name: "RoleCollection");
migrationBuilder.DropTable(
name: "Collections");
}
}
}

View File

@ -36,6 +36,21 @@ namespace LANCommander.Migrations
b.ToTable("CategoryGame");
});
modelBuilder.Entity("CollectionGame", b =>
{
b.Property<Guid>("CollectionId")
.HasColumnType("TEXT");
b.Property<Guid>("GameId")
.HasColumnType("TEXT");
b.HasKey("CollectionId", "GameId");
b.HasIndex("GameId");
b.ToTable("CollectionGame");
});
modelBuilder.Entity("GameDeveloper", b =>
{
b.Property<Guid>("DeveloperId")
@ -257,6 +272,37 @@ namespace LANCommander.Migrations
b.ToTable("Categories");
});
modelBuilder.Entity("LANCommander.Data.Models.Collection", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("CreatedById")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid?>("UpdatedById")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatedById");
b.HasIndex("UpdatedById");
b.ToTable("Collections");
});
modelBuilder.Entity("LANCommander.Data.Models.Company", b =>
{
b.Property<Guid>("Id")
@ -1157,6 +1203,21 @@ namespace LANCommander.Migrations
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("RoleCollection", b =>
{
b.Property<Guid>("CollectionId")
.HasColumnType("TEXT");
b.Property<Guid>("RoleId")
.HasColumnType("TEXT");
b.HasKey("CollectionId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("RoleCollection");
});
modelBuilder.Entity("CategoryGame", b =>
{
b.HasOne("LANCommander.Data.Models.Category", null)
@ -1172,6 +1233,21 @@ namespace LANCommander.Migrations
.IsRequired();
});
modelBuilder.Entity("CollectionGame", b =>
{
b.HasOne("LANCommander.Data.Models.Collection", null)
.WithMany()
.HasForeignKey("CollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LANCommander.Data.Models.Game", null)
.WithMany()
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("GameDeveloper", b =>
{
b.HasOne("LANCommander.Data.Models.Company", null)
@ -1332,6 +1408,23 @@ namespace LANCommander.Migrations
b.Navigation("UpdatedBy");
});
modelBuilder.Entity("LANCommander.Data.Models.Collection", b =>
{
b.HasOne("LANCommander.Data.Models.User", "CreatedBy")
.WithMany()
.HasForeignKey("CreatedById")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("LANCommander.Data.Models.User", "UpdatedBy")
.WithMany()
.HasForeignKey("UpdatedById")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CreatedBy");
b.Navigation("UpdatedBy");
});
modelBuilder.Entity("LANCommander.Data.Models.Company", b =>
{
b.HasOne("LANCommander.Data.Models.User", "CreatedBy")
@ -1750,6 +1843,21 @@ namespace LANCommander.Migrations
.IsRequired();
});
modelBuilder.Entity("RoleCollection", b =>
{
b.HasOne("LANCommander.Data.Models.Collection", null)
.WithMany()
.HasForeignKey("CollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LANCommander.Data.Models.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("LANCommander.Data.Models.Category", b =>
{
b.Navigation("Children");

View File

@ -0,0 +1,7 @@
namespace LANCommander.Models
{
public class AddToCollectionOptions
{
public IEnumerable<Guid> GameIds { get; set; }
}
}

View File

@ -0,0 +1,145 @@
@page "/Collections/{id:guid}"
@page "/Collections/Add"
@using LANCommander.Data.Enums;
@using LANCommander.Extensions
@attribute [Authorize(Roles = "Administrator")]
@inject CollectionService CollectionService
@inject IMessageService MessageService
@inject NavigationManager NavigationManager
<PageHeader>
<PageHeaderTitle>
<Input Size="@InputSize.Large" @bind-Value="@Collection.Name" />
</PageHeaderTitle>
<PageHeaderExtra>
<Button Type="@ButtonType.Primary" OnClick="Save">Save</Button>
</PageHeaderExtra>
</PageHeader>
<Table TItem="Game" DataSource="@Collection.Games" Responsive>
<Column TData="string" Title="Icon">
<Image Src="@GetIcon(context)" Height="32" Width="32" Preview="false"></Image>
</Column>
<PropertyColumn Property="g => g.Title" Sortable Filterable />
<PropertyColumn Property="g => g.ReleasedOn" Format="MM/dd/yyyy" Sortable Filterable />
<PropertyColumn Property="g => g.Singleplayer" Sortable Filterable>
<Checkbox Disabled="true" Checked="context.Singleplayer" />
</PropertyColumn>
<Column TData="bool" Title="Multiplayer">
<Checkbox Disabled="true" Checked="context.MultiplayerModes?.Count > 0" />
</Column>
<Column TData="string[]" Title="Developers">
@foreach (var dev in context.Developers)
{
<Tag>@dev.Name</Tag>
}
</Column>
<Column TData="string[]" Title="Publishers">
@foreach (var pub in context.Publishers)
{
<Tag>@pub.Name</Tag>
}
</Column>
<Column TData="string[]" Title="Genres">
@foreach (var genre in context.Genres)
{
<Tag>@genre.Name</Tag>
}
</Column>
<Column TData="Data.Enums.MultiplayerType[]" Title="Multiplayer Modes">
@foreach (var mode in context.MultiplayerModes.Select(mm => mm.Type).Distinct())
{
<Tag>@mode.GetDisplayName()</Tag>
}
</Column>
<ActionColumn Title="" Style="text-align: right">
<ChildContent>
<Space Direction="DirectionVHType.Horizontal">
<SpaceItem>
<Popconfirm OnConfirm="() => RemoveGame(context)" Title="Are you sure you want to remove this game from the collection?">
<Button Icon="@IconType.Outline.Close" Type="@ButtonType.Text" Danger />
</Popconfirm>
</SpaceItem>
</Space>
</ChildContent>
</ActionColumn>
</Table>
@code {
[Parameter] public Guid Id { get; set; }
Collection Collection;
protected override async Task OnInitializedAsync()
{
if (Id == Guid.Empty)
Collection = new Collection();
else
Collection = await CollectionService.Get(Id);
}
private async Task Save()
{
try
{
if (Collection.Id != Guid.Empty)
{
Collection = await CollectionService.Update(Collection);
await MessageService.Success("Collection updated!");
}
else
{
Collection = await CollectionService.Add(Collection);
NavigationManager.LocationChanged += NotifyCollectionAdded;
NavigationManager.NavigateTo($"/Collections/{Collection.Id}");
}
}
catch (Exception ex)
{
await MessageService.Error("Could not save!");
}
}
private void NotifyCollectionAdded(object? sender, LocationChangedEventArgs e)
{
NavigationManager.LocationChanged -= NotifyCollectionAdded;
MessageService.Success("Collection added!");
}
private async Task RemoveGame(Game game)
{
try
{
Collection.Games.Remove(game);
await CollectionService.Update(Collection);
}
catch (Exception ex)
{
MessageService.Error("Game could not be removed from the collection!");
}
}
private string GetIcon(Game game)
{
var media = game?.Media?.FirstOrDefault(m => m.Type == Data.Enums.MediaType.Icon);
if (media != null)
return $"/api/Media/{media.Id}/Download?fileId={media.FileId}";
else
return "/favicon.ico";
}
}

View File

@ -0,0 +1,110 @@
@page "/Collections"
@using Microsoft.EntityFrameworkCore;
@attribute [Authorize(Roles = "Administrator")]
@inject CollectionService CollectionService
@inject NavigationManager NavigationManager
@inject IMessageService MessageService
<PageHeader Title="Collections">
<PageHeaderExtra>
<Space Direction="DirectionVHType.Horizontal">
<SpaceItem>
<Search Placeholder="Search" @bind-Value="Search" BindOnInput DebounceMilliseconds="150" OnChange="() => LoadData()" />
</SpaceItem>
</Space>
</PageHeaderExtra>
</PageHeader>
<TableColumnPicker @ref="Picker" Key="Collections" @bind-Visible="ColumnPickerVisible" />
<Table TItem="Collection" DataSource="@Collections" Loading="@Loading" PageSize="25" Responsive>
<PropertyColumn Property="r => r.Name" Sortable Hidden="@(Picker.IsColumnHidden("Name"))" />
<PropertyColumn Property="s => s.CreatedOn" Format="MM/dd/yyyy hh:mm tt" Sortable Hidden="@(Picker.IsColumnHidden("Created On"))" />
<PropertyColumn Property="s => s.CreatedBy" Sortable Hidden="@(Picker.IsColumnHidden("Created By"))">
@context.CreatedBy?.UserName
</PropertyColumn>
<PropertyColumn Property="g => g.UpdatedOn" Format="MM/dd/yyyy hh:mm tt" Sortable Hidden="@(Picker.IsColumnHidden("Updated On"))" />
<PropertyColumn Property="g => g.UpdatedBy" Sortable Hidden="@(Picker.IsColumnHidden("Updated By"))">
@context.UpdatedBy?.UserName
</PropertyColumn>
<Column Title="Games" TData="int">
@context.Games.Count
</Column>
<ActionColumn Title="" Style="text-align: right; white-space: nowrap">
<TitleTemplate>
<div style="text-align: right">
<Button Icon="@IconType.Outline.Edit" Type="@ButtonType.Text" OnClick="() => OpenColumnPicker()" />
</div>
</TitleTemplate>
<ChildContent>
<Space Direction="DirectionVHType.Horizontal">
<SpaceItem>
<a href="/Collections/@(context.Id)" class="ant-btn ant-btn-primary">Edit</a>
</SpaceItem>
<SpaceItem>
<Popconfirm OnConfirm="() => Delete(context)" Title="Are you sure you want to delete this Collection?">
<Button Icon="@IconType.Outline.Close" Type="@ButtonType.Text" Danger />
</Popconfirm>
</SpaceItem>
</Space>
</ChildContent>
</ActionColumn>
</Table>
@code {
IEnumerable<Collection> Collections { get; set; } = new List<Collection>();
bool Loading = true;
string Search = "";
TableColumnPicker Picker;
bool ColumnPickerVisible = false;
protected override void OnAfterRender(bool firstRender)
{
if (firstRender)
{
LoadData();
Loading = false;
StateHasChanged();
}
}
private async Task LoadData()
{
var fuzzySearch = Search.ToLower().Trim();
Collections = await CollectionService.Get(r => r.Name.ToLower().Contains(fuzzySearch)).OrderBy(r => r.Name).ToListAsync();
}
private void Add()
{
NavigationManager.NavigateTo("/Collections/Add");
}
private async Task Delete(Collection Collection)
{
Collections = new List<Collection>();
Loading = true;
await CollectionService.Delete(Collection);
Collections = await CollectionService.Get(x => true).OrderBy(r => r.Name).ToListAsync();
Loading = false;
}
private async Task OpenColumnPicker()
{
ColumnPickerVisible = true;
}
private async Task CloseColumnPicker()
{
ColumnPickerVisible = false;
}
}

View File

@ -0,0 +1,107 @@
@using LANCommander.Models
@inherits FeedbackComponent<AddToCollectionOptions, Collection>
@inject CollectionService CollectionService
@inject GameService GameService
@inject IMessageService MessageService
<Select
TItem="Collection"
TItemValue="Guid"
DataSource="@Collections"
@bind-Value="SelectedCollection"
LabelName="@nameof(Collection.Name)"
ValueName="@nameof(Collection.Id)"
Placeholder="Select a Collection"
DropdownRender="@DropdownRender"
OnSelectedItemChanged="OnSelectedItemChanged" />
@code {
ICollection<Collection> Collections = new List<Collection>();
Guid SelectedCollection;
string NewCollectionName;
protected override async Task OnInitializedAsync()
{
await LoadData();
}
private async Task LoadData()
{
Collections = (await CollectionService.Get()).OrderBy(c => c.Name).ToList();
}
private RenderFragment DropdownRender(RenderFragment originNode)
{
RenderFragment customDropdownRender =
@<Template>
<div>
@originNode
<Divider Style="margin: 4px 0"></Divider>
<div style="display: flex; flex-wrap: nowrap; padding: 8px">
<Input Style="flex: auto" @bind-Value="@NewCollectionName" />
<a style="flex: none; padding: 8px; display: block; cursor: pointer" @onclick="AddCollection">
<Icon Type="plus" Theme="outline"></Icon>
Add New Collection
</a>
</div>
</div>
</Template>
;
return customDropdownRender;
}
private async Task AddCollection(MouseEventArgs args)
{
try
{
if (!String.IsNullOrWhiteSpace(NewCollectionName))
{
await CollectionService.Add(new Collection()
{
Name = NewCollectionName
});
await LoadData();
MessageService.Success("Collection added!");
}
}
catch (Exception ex)
{
MessageService.Error("Could not add a new collection!");
}
}
private void OnSelectedItemChanged(Collection collection)
{
SelectedCollection = collection.Id;
}
public override async Task OnFeedbackOkAsync(ModalClosingEventArgs args)
{
var collection = await CollectionService.Get(SelectedCollection);
try
{
foreach (var gameId in Options.GameIds.Where(gid => collection.Games != null && !collection.Games.Any(g => g.Id == gid)))
{
var game = await GameService.Get(gameId);
collection.Games.Add(game);
}
await CollectionService.Update(collection);
MessageService.Success("Added to collection!");
}
catch (Exception ex)
{
MessageService.Error("Could not add to collection!");
}
await base.OkCancelRefWithResult!.OnOk(collection);
}
}

View File

@ -2,18 +2,26 @@
@using AntDesign.TableModels;
@using LANCommander.Extensions;
@using System.ComponentModel.DataAnnotations;
@using LANCommander.Models
@using LANCommander.Pages.Games.Components
@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
@using Microsoft.EntityFrameworkCore;
@using System.Web
@attribute [Authorize]
@inject GameService GameService
@inject NavigationManager NavigationManager
@inject ModalService ModalService
@inject IMessageService MessageService
<PageHeader Title="Games" Subtitle="@Games.Count().ToString()">
<PageHeaderExtra>
<Space Direction="DirectionVHType.Horizontal">
@if (Selected != null && Selected.Count() > 0)
{
<SpaceItem>
<Button OnClick="() => AddToCollection()" Type="@ButtonType.Primary">Add to Collection</Button>
</SpaceItem>
}
<SpaceItem>
<Search Placeholder="Search" @bind-Value="Search" BindOnInput DebounceMilliseconds="250" OnChange="SearchChanged" />
</SpaceItem>
@ -26,7 +34,8 @@
<TableColumnPicker @ref="Picker" Key="Games" @bind-Visible="ColumnPickerVisible" />
<Table TItem="Game" DataSource="@Games" Loading="@Loading" PageSize="@PageSize" PageIndex="@PageIndex" OnPageIndexChange="PageIndexChanged" OnPageSizeChange="PageSizeChanged" Responsive>
<Table TItem="Game" DataSource="@Games" @bind-SelectedRows="Selected" Loading="@Loading" PageSize="@PageSize" PageIndex="@PageIndex" OnPageIndexChange="PageIndexChanged" OnPageSizeChange="PageSizeChanged" Responsive>
<Selection Key="@(context.Id.ToString())" />
<Column TData="string" Title="Icon" Hidden="@(Picker.IsColumnHidden("Icon"))">
<Image Src="@GetIcon(context)" Height="32" Width="32" Preview="false"></Image>
</Column>
@ -127,6 +136,8 @@
bool Visibility = false;
IEnumerable<Game> Selected;
TableColumnPicker Picker;
bool ColumnPickerVisible = false;
@ -231,6 +242,31 @@
Loading = false;
}
private async void AddToCollection()
{
var modalOptions = new ModalOptions()
{
Title = "Add to Collection",
Maximizable = false,
DefaultMaximized = false,
Closable = true,
OkText = "Add"
};
var options = new AddToCollectionOptions()
{
GameIds = Selected.Select(g => g.Id)
};
var modalRef = await ModalService.CreateModalAsync<AddToCollectionDialog, AddToCollectionOptions, Collection>(modalOptions, options);
modalRef.OnOk = async (collection) =>
{
Selected = null;
await LoadData();
};
}
private async Task OpenColumnPicker()
{
ColumnPickerVisible = true;

View File

@ -133,6 +133,7 @@ namespace LANCommander
builder.Services.AddScoped<SettingService>();
builder.Services.AddScoped<ArchiveService>();
builder.Services.AddScoped<CategoryService>();
builder.Services.AddScoped<CollectionService>();
builder.Services.AddScoped<GameService>();
builder.Services.AddScoped<ScriptService>();
builder.Services.AddScoped<GenreService>();

View File

@ -0,0 +1,12 @@
using LANCommander.Data;
using LANCommander.Data.Models;
namespace LANCommander.Services
{
public class CollectionService : BaseDatabaseService<Collection>
{
public CollectionService(DatabaseContext dbContext, IHttpContextAccessor httpContextAccessor) : base(dbContext, httpContextAccessor)
{
}
}
}