Add linked suggestions with synced voting
This commit is contained in:
@@ -2,8 +2,9 @@ namespace GameList.Contracts;
|
||||
|
||||
public record SetNameRequest(string Name);
|
||||
public record SuggestionRequest(string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers);
|
||||
public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers);
|
||||
public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, int? ParentSuggestionId = null, IReadOnlyList<int>? LinkedIds = null, IReadOnlyList<string>? LinkedTitles = null);
|
||||
public record VoteRequest(int SuggestionId, int Score);
|
||||
public record ResultsOpenRequest(bool ResultsOpen);
|
||||
public record VoteFinalizeRequest(bool Final);
|
||||
public record VoteStatusDto(Guid PlayerId, string Name, bool Finalized);
|
||||
public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId);
|
||||
|
||||
@@ -49,6 +49,11 @@ public class AppDbContext : DbContext
|
||||
builder.Property(s => s.GameUrl).HasMaxLength(2048);
|
||||
builder.Property(s => s.MinPlayers);
|
||||
builder.Property(s => s.MaxPlayers);
|
||||
builder.HasOne(s => s.ParentSuggestion)
|
||||
.WithMany(p => p.LinkedSuggestions)
|
||||
.HasForeignKey(s => s.ParentSuggestionId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasIndex(s => s.ParentSuggestionId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Vote>(builder =>
|
||||
|
||||
241
Data/Migrations/20260205075456_AddSuggestionLinks.Designer.cs
generated
Normal file
241
Data/Migrations/20260205075456_AddSuggestionLinks.Designer.cs
generated
Normal file
@@ -0,0 +1,241 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using GameList.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GameList.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260205075456_AddSuggestionLinks")]
|
||||
partial class AddSuggestionLinks
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.AppState", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ResultsOpen")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppState");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
ResultsOpen = false,
|
||||
UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Player", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrentPhase")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<byte[]>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("VotesFinal")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedUsername")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Players");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GameUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("MaxPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("MinPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentSuggestionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PlayerId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ScreenshotUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("YoutubeUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentSuggestionId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("Suggestions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("PlayerId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SuggestionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SuggestionId");
|
||||
|
||||
b.HasIndex("PlayerId", "SuggestionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Votes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.HasOne("GameList.Domain.Suggestion", "ParentSuggestion")
|
||||
.WithMany("LinkedSuggestions")
|
||||
.HasForeignKey("ParentSuggestionId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("GameList.Domain.Player", "Player")
|
||||
.WithMany("Suggestions")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ParentSuggestion");
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
||||
{
|
||||
b.HasOne("GameList.Domain.Player", "Player")
|
||||
.WithMany("Votes")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("GameList.Domain.Suggestion", "Suggestion")
|
||||
.WithMany("Votes")
|
||||
.HasForeignKey("SuggestionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
|
||||
b.Navigation("Suggestion");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Player", b =>
|
||||
{
|
||||
b.Navigation("Suggestions");
|
||||
|
||||
b.Navigation("Votes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.Navigation("LinkedSuggestions");
|
||||
|
||||
b.Navigation("Votes");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
49
Data/Migrations/20260205075456_AddSuggestionLinks.cs
Normal file
49
Data/Migrations/20260205075456_AddSuggestionLinks.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GameList.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSuggestionLinks : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ParentSuggestionId",
|
||||
table: "Suggestions",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Suggestions_ParentSuggestionId",
|
||||
table: "Suggestions",
|
||||
column: "ParentSuggestionId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Suggestions_Suggestions_ParentSuggestionId",
|
||||
table: "Suggestions",
|
||||
column: "ParentSuggestionId",
|
||||
principalTable: "Suggestions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Suggestions_Suggestions_ParentSuggestionId",
|
||||
table: "Suggestions");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Suggestions_ParentSuggestionId",
|
||||
table: "Suggestions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ParentSuggestionId",
|
||||
table: "Suggestions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,9 @@ namespace GameList.Data.Migrations
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentSuggestionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PlayerId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -144,6 +147,8 @@ namespace GameList.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentSuggestionId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("Suggestions");
|
||||
@@ -179,12 +184,19 @@ namespace GameList.Data.Migrations
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.HasOne("GameList.Domain.Suggestion", "ParentSuggestion")
|
||||
.WithMany("LinkedSuggestions")
|
||||
.HasForeignKey("ParentSuggestionId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("GameList.Domain.Player", "Player")
|
||||
.WithMany("Suggestions")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ParentSuggestion");
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
@@ -216,6 +228,8 @@ namespace GameList.Data.Migrations
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.Navigation("LinkedSuggestions");
|
||||
|
||||
b.Navigation("Votes");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
|
||||
@@ -34,5 +34,9 @@ public class Suggestion
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public int? ParentSuggestionId { get; set; }
|
||||
public Suggestion? ParentSuggestion { get; set; }
|
||||
public ICollection<Suggestion> LinkedSuggestions { get; set; } = new List<Suggestion>();
|
||||
|
||||
public ICollection<Vote> Votes { get; set; } = new List<Vote>();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using GameList.Domain;
|
||||
using GameList.Contracts;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
@@ -51,6 +52,78 @@ public static class AdminEndpoints
|
||||
return Results.Ok(new { voters, ready, waiting });
|
||||
});
|
||||
|
||||
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null || !await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
|
||||
if (request.SourceSuggestionId == request.TargetSuggestionId)
|
||||
return Results.BadRequest(new { error = "Pick two different games to link." });
|
||||
|
||||
var suggestions = await db.Suggestions.ToListAsync();
|
||||
var source = suggestions.FirstOrDefault(s => s.Id == request.SourceSuggestionId);
|
||||
var target = suggestions.FirstOrDefault(s => s.Id == request.TargetSuggestionId);
|
||||
if (source is null || target is null)
|
||||
return Results.NotFound(new { error = "Suggestion not found." });
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
if (!rootIndex.TryGetValue(source.Id, out var sourceRoot) || !rootIndex.TryGetValue(target.Id, out var targetRoot))
|
||||
return Results.NotFound(new { error = "Suggestion not found." });
|
||||
|
||||
if (sourceRoot == targetRoot)
|
||||
return Results.BadRequest(new { error = "These games are already linked." });
|
||||
|
||||
var affectedRootIds = new HashSet<int> { sourceRoot, targetRoot };
|
||||
var affectedIds = rootIndex
|
||||
.Where(kv => affectedRootIds.Contains(kv.Value))
|
||||
.Select(kv => kv.Key)
|
||||
.ToList();
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
foreach (var suggestion in suggestions)
|
||||
{
|
||||
var root = rootIndex.GetValueOrDefault(suggestion.Id);
|
||||
if (root == targetRoot)
|
||||
{
|
||||
suggestion.ParentSuggestionId = suggestion.Id == targetRoot ? null : targetRoot;
|
||||
}
|
||||
else if (root == sourceRoot)
|
||||
{
|
||||
suggestion.ParentSuggestionId = targetRoot;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var affectedPlayerIds = await db.Votes
|
||||
.Where(v => affectedIds.Contains(v.SuggestionId))
|
||||
.Select(v => v.PlayerId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
|
||||
|
||||
if (affectedPlayerIds.Count > 0)
|
||||
{
|
||||
await db.Players.Where(p => affectedPlayerIds.Contains(p.Id))
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
RootId = targetRoot,
|
||||
LinkedSuggestionIds = affectedIds,
|
||||
UnfinalizedPlayers = affectedPlayerIds.Count
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
{
|
||||
if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -163,4 +164,35 @@ internal static class EndpointHelpers
|
||||
ResultsOpen = false,
|
||||
UpdatedAt = DateTimeOffset.UnixEpoch
|
||||
};
|
||||
|
||||
public static Dictionary<int, int> BuildLinkRoots(IEnumerable<(int Id, int? ParentId)> items)
|
||||
{
|
||||
var parentMap = items.ToDictionary(x => x.Id, x => x.ParentId);
|
||||
var roots = new Dictionary<int, int>();
|
||||
foreach (var id in parentMap.Keys)
|
||||
{
|
||||
roots[id] = FindRootId(id, parentMap);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
public static int FindRootId(int suggestionId, IReadOnlyDictionary<int, int?> parentMap)
|
||||
{
|
||||
var current = suggestionId;
|
||||
var visited = new HashSet<int>();
|
||||
|
||||
while (parentMap.TryGetValue(current, out var parent) && parent is int p && !visited.Contains(p))
|
||||
{
|
||||
visited.Add(current);
|
||||
current = p;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
public static List<int> LinkedIdsFor(int suggestionId, IReadOnlyDictionary<int, int> rootIndex)
|
||||
{
|
||||
if (!rootIndex.TryGetValue(suggestionId, out var root)) return new();
|
||||
return rootIndex.Where(kv => kv.Value == root).Select(kv => kv.Key).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +46,47 @@ public static class ResultsEndpoints
|
||||
s.GameUrl,
|
||||
s.Description,
|
||||
s.Genre,
|
||||
s.ParentSuggestionId
|
||||
})
|
||||
.OrderByDescending(r => r.Average)
|
||||
.ToListAsync();
|
||||
|
||||
return Results.Ok(results);
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(results.Select(r => (r.Id, r.ParentSuggestionId)));
|
||||
var nameLookup = results.ToDictionary(r => r.Id, r => r.Name);
|
||||
|
||||
var shaped = results.Select(r =>
|
||||
{
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex)
|
||||
.Where(id => id != r.Id)
|
||||
.ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
r.Id,
|
||||
r.Name,
|
||||
r.Author,
|
||||
r.MinPlayers,
|
||||
r.MaxPlayers,
|
||||
r.Total,
|
||||
r.Count,
|
||||
r.Average,
|
||||
r.Votes,
|
||||
r.MyVote,
|
||||
r.ScreenshotUrl,
|
||||
r.YoutubeUrl,
|
||||
r.GameUrl,
|
||||
r.Description,
|
||||
r.Genre,
|
||||
r.ParentSuggestionId,
|
||||
LinkedIds = linkedIds,
|
||||
LinkedTitles = linkedIds
|
||||
.Where(id => nameLookup.ContainsKey(id))
|
||||
.Select(id => nameLookup[id])
|
||||
.ToList()
|
||||
};
|
||||
});
|
||||
|
||||
return Results.Ok(shaped);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,13 +28,14 @@ public static class SuggestEndpoints
|
||||
s.GameUrl,
|
||||
s.CreatedAt,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers
|
||||
s.MaxPlayers,
|
||||
s.ParentSuggestionId
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var ordered = mine
|
||||
.OrderBy(s => s.CreatedAt)
|
||||
.Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers));
|
||||
.Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId));
|
||||
|
||||
return Results.Ok(ordered);
|
||||
});
|
||||
@@ -206,25 +207,42 @@ public static class SuggestEndpoints
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
Author = s.Player!.DisplayName,
|
||||
s.CreatedAt
|
||||
s.CreatedAt,
|
||||
s.ParentSuggestionId
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
|
||||
|
||||
var ordered = all
|
||||
.OrderBy(s => s.CreatedAt)
|
||||
.Select(s => new
|
||||
.Select(s =>
|
||||
{
|
||||
s.Id,
|
||||
s.PlayerId,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
s.Author
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex)
|
||||
.Where(id => id != s.Id)
|
||||
.ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
s.Id,
|
||||
s.PlayerId,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
s.Author,
|
||||
s.ParentSuggestionId,
|
||||
LinkedIds = linkedIds,
|
||||
LinkedTitles = linkedIds
|
||||
.Where(id => nameLookup.ContainsKey(id))
|
||||
.Select(id => nameLookup[id])
|
||||
.ToList()
|
||||
};
|
||||
});
|
||||
|
||||
return Results.Ok(ordered);
|
||||
|
||||
@@ -41,28 +41,40 @@ public static class VoteEndpoints
|
||||
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||
return Results.BadRequest(new { error = "Set a display name before voting." });
|
||||
|
||||
var suggestionExists = await db.Suggestions.AnyAsync(s => s.Id == request.SuggestionId);
|
||||
if (!suggestionExists)
|
||||
var linkMap = await db.Suggestions.AsNoTracking()
|
||||
.Select(s => new { s.Id, s.ParentSuggestionId })
|
||||
.ToListAsync();
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
if (!rootIndex.ContainsKey(request.SuggestionId))
|
||||
return Results.BadRequest(new { error = "Suggestion not found." });
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(request.SuggestionId, rootIndex);
|
||||
if (linkedIds.Count == 0)
|
||||
linkedIds.Add(request.SuggestionId);
|
||||
|
||||
var vote = await db.Votes.FirstOrDefaultAsync(v => v.PlayerId == player.Id && v.SuggestionId == request.SuggestionId);
|
||||
if (vote == null)
|
||||
var existingVotes = await db.Votes
|
||||
.Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var suggestionId in linkedIds)
|
||||
{
|
||||
vote = new Vote
|
||||
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == suggestionId);
|
||||
if (vote == null)
|
||||
{
|
||||
PlayerId = player.Id,
|
||||
SuggestionId = request.SuggestionId,
|
||||
Score = request.Score
|
||||
};
|
||||
db.Votes.Add(vote);
|
||||
}
|
||||
else
|
||||
{
|
||||
vote.Score = request.Score;
|
||||
db.Votes.Add(new Vote
|
||||
{
|
||||
PlayerId = player.Id,
|
||||
SuggestionId = suggestionId,
|
||||
Score = request.Score
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
vote.Score = request.Score;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new { vote.Id, vote.Score });
|
||||
return Results.Ok(new { SuggestionIds = linkedIds, request.Score });
|
||||
});
|
||||
|
||||
app.MapPost("/api/votes/finalize", async ([FromBody] VoteFinalizeRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
|
||||
@@ -177,6 +177,24 @@ function setupHandlers() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const linkApply = $("link-apply");
|
||||
if (linkApply) {
|
||||
linkApply.addEventListener("click", async () => {
|
||||
const source = Number($("link-source")?.value);
|
||||
const target = Number($("link-target")?.value);
|
||||
if (!source || !target || source === target) {
|
||||
return toast(t("admin.linkValidation"), true);
|
||||
}
|
||||
try {
|
||||
await adminApi.linkSuggestions(source, target);
|
||||
toast(t("admin.linkDone"));
|
||||
await refreshPhaseData();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function adminAction(fn, successMessage) {
|
||||
|
||||
@@ -29,3 +29,11 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#admin-linker select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e3d4bd;
|
||||
background: #fffaf3;
|
||||
}
|
||||
|
||||
@@ -99,6 +99,11 @@ button .chip {
|
||||
width: 30px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.chip.link-chip {
|
||||
background: #d7e7ff;
|
||||
border: 1px solid #b9d1ff;
|
||||
color: #1b3d75;
|
||||
}
|
||||
.chip.danger-chip {
|
||||
background: #e0564f;
|
||||
border: 1px solid #c54740;
|
||||
|
||||
@@ -151,6 +151,19 @@
|
||||
<input type="checkbox" id="results-open" />
|
||||
<span data-i18n="admin.resultsOpenToggle">Allow results phase</span>
|
||||
</label>
|
||||
<div class="stack hidden" id="admin-linker">
|
||||
<h4 data-i18n="admin.linkTitle">Link games</h4>
|
||||
<p class="muted small" data-i18n="admin.linkHint">Use during voting to merge duplicates. Linking clears votes and unfinalizes voters.</p>
|
||||
<label class="stack">
|
||||
<span class="label" data-i18n="admin.linkSource">Game to link</span>
|
||||
<select id="link-source"></select>
|
||||
</label>
|
||||
<label class="stack">
|
||||
<span class="label" data-i18n="admin.linkTarget">Link to (parent)</span>
|
||||
<select id="link-target"></select>
|
||||
</label>
|
||||
<button id="link-apply" class="secondary" type="button" data-i18n="admin.linkAction">Link & clear votes</button>
|
||||
</div>
|
||||
<div class="stack horizontal">
|
||||
<button id="reset" class="danger" data-i18n="admin.reset">Reset (keep players)</button>
|
||||
<button id="factory-reset" class="danger" data-i18n="admin.factoryReset">Factory reset</button>
|
||||
|
||||
@@ -56,4 +56,6 @@ export const adminApi = {
|
||||
voteStatus: () => request("/api/admin/vote-status"),
|
||||
reset: () => request("/api/admin/reset", { method: "POST" }),
|
||||
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
|
||||
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
|
||||
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
|
||||
};
|
||||
|
||||
@@ -91,6 +91,7 @@ export function signatureSuggestions(list) {
|
||||
s.gameUrl,
|
||||
s.minPlayers,
|
||||
s.maxPlayers,
|
||||
s.parentSuggestionId,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,8 @@ const translations = {
|
||||
"card.site": "Site ↗",
|
||||
"card.youtube": "YouTube ↗",
|
||||
"card.openScreenshot": "Open screenshot",
|
||||
"card.linked": "Votes linked",
|
||||
"card.linkedWith": "Linked with: {names}",
|
||||
|
||||
"vote.saved": "Saved vote",
|
||||
"vote.missing": "Missing",
|
||||
@@ -107,6 +109,15 @@ const translations = {
|
||||
"admin.factoryResetDone": "Factory reset complete",
|
||||
"admin.readyForResults": "Ready for results",
|
||||
"admin.waitingForPlayers": "Waiting for players: {names}",
|
||||
"admin.linkTitle": "Link games",
|
||||
"admin.linkHint": "Use during voting to merge duplicates. Linking clears votes and unfinalizes voters.",
|
||||
"admin.linkSource": "Game to link",
|
||||
"admin.linkTarget": "Link to (parent)",
|
||||
"admin.linkAction": "Link & clear votes",
|
||||
"admin.linkSourcePlaceholder": "Select game A",
|
||||
"admin.linkTargetPlaceholder": "Select game B (parent)",
|
||||
"admin.linkValidation": "Choose two different games to link.",
|
||||
"admin.linkDone": "Games linked. Votes cleared.",
|
||||
|
||||
"toast.unexpected": "Unexpected error",
|
||||
"toast.registered": "Registered",
|
||||
@@ -204,6 +215,8 @@ const translations = {
|
||||
"card.site": "Webseite ↗",
|
||||
"card.youtube": "YouTube ↗",
|
||||
"card.openScreenshot": "Screenshot öffnen",
|
||||
"card.linked": "Verknüpfte Stimmen",
|
||||
"card.linkedWith": "Verknüpft mit: {names}",
|
||||
|
||||
"vote.saved": "Stimme gespeichert",
|
||||
"vote.missing": "Fehlt",
|
||||
@@ -236,6 +249,15 @@ const translations = {
|
||||
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
|
||||
"admin.readyForResults": "Bereit für Ergebnisse",
|
||||
"admin.waitingForPlayers": "Warten auf: {names}",
|
||||
"admin.linkTitle": "Spiele verknüpfen",
|
||||
"admin.linkHint": "Nutze dies in der Bewertungsphase, um Duplikate zu verbinden. Das löscht die Stimmen der verknüpften Spiele und hebt Finalisierungen auf.",
|
||||
"admin.linkSource": "Spiel verknüpfen",
|
||||
"admin.linkTarget": "Verknüpfen mit (Eltern)",
|
||||
"admin.linkAction": "Verknüpfen & Stimmen löschen",
|
||||
"admin.linkSourcePlaceholder": "Spiel A wählen",
|
||||
"admin.linkTargetPlaceholder": "Spiel B (Eltern) wählen",
|
||||
"admin.linkValidation": "Wähle zwei verschiedene Spiele aus.",
|
||||
"admin.linkDone": "Spiele verknüpft. Stimmen gelöscht.",
|
||||
|
||||
"toast.unexpected": "Unerwarteter Fehler",
|
||||
"toast.registered": "Registriert",
|
||||
|
||||
141
wwwroot/js/ui.js
141
wwwroot/js/ui.js
@@ -115,6 +115,7 @@ export function renderMySuggestions() {
|
||||
}
|
||||
|
||||
export function renderAllSuggestions() {
|
||||
renderAdminLinker();
|
||||
const list = $("all-suggestions");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
@@ -147,12 +148,14 @@ export function renderVotes() {
|
||||
const current = hasVote ? votesMap[s.id] : 5; // start neutral when no prior vote
|
||||
const displayScore = hasVote ? current : "—";
|
||||
const displayEmoji = hasVote ? scoreToEmoji(current) : "⚠️";
|
||||
const linkedIds = linkedPeerIds(s);
|
||||
const rootId = linkRootId(s);
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "vote-controls";
|
||||
footer.innerHTML = `
|
||||
<div class="warning-text ${hasVote ? "hidden" : ""}" id="warn-${s.id}">${state.votesFinal ? t("vote.missingFinalWarn") : t("vote.missingWarn")}</div>
|
||||
<div class="vote-row">
|
||||
<input class="full-slider" type="range" min="0" max="10" value="${current}" data-id="${s.id}" ${state.votesFinal ? "disabled" : ""}>
|
||||
<input class="full-slider" type="range" min="0" max="10" value="${current}" data-id="${s.id}" data-root="${rootId}" data-linked="${linkedIds.join(",")}" ${state.votesFinal ? "disabled" : ""}>
|
||||
<span class="score" id="score-${s.id}">${displayScore}</span>
|
||||
<span class="score-emoji" id="emoji-${s.id}">${displayEmoji}</span>
|
||||
</div>`;
|
||||
@@ -169,6 +172,7 @@ export function renderVotes() {
|
||||
if (emojiEl) emojiEl.textContent = scoreToEmoji(val);
|
||||
const warn = $("warn-" + e.target.dataset.id);
|
||||
if (warn) warn.classList.add("hidden");
|
||||
syncLinkedSliders(e.target, val);
|
||||
});
|
||||
input.addEventListener("change", async (e) => {
|
||||
if (state.votesFinal) return;
|
||||
@@ -244,7 +248,7 @@ export function renderResults() {
|
||||
<td class="game-cell">
|
||||
${r.screenshotUrl ? `<img class="thumb clickable-thumb" src="${r.screenshotUrl}" alt="${r.name}">` : ''}
|
||||
<div class="game-meta">
|
||||
<div class="title-line">${r.name}</div>
|
||||
<div class="title-line">${r.name} ${renderLinkBadge(r)}</div>
|
||||
${buildResultMeta(r)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -296,6 +300,14 @@ export function buildCard(
|
||||
const card = document.createElement("article");
|
||||
card.className = "game-card";
|
||||
const hasImage = !!s.screenshotUrl;
|
||||
const linkedTitles = linkedPeerTitles(s);
|
||||
const linked = isLinked(s);
|
||||
const linkTooltip = linked
|
||||
? linkedTitles.length > 0
|
||||
? t("card.linkedWith", { names: linkedTitles.join(", ") })
|
||||
: t("card.linked")
|
||||
: "";
|
||||
const linkChip = linked ? `<span class="chip icon link-chip" title="${linkTooltip}">🔗</span>` : "";
|
||||
const visual = hasImage
|
||||
? `<button class="card-visual" data-img="${s.screenshotUrl}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${s.screenshotUrl}')"></button>`
|
||||
: `<div class="card-visual"></div>`;
|
||||
@@ -320,6 +332,7 @@ export function buildCard(
|
||||
<div class="card-title-row">
|
||||
<h3 class="card-title" title="${s.name}">${s.name}</h3>
|
||||
<div class="title-meta">
|
||||
${linkChip}
|
||||
${showAuthor && s.author ? `<span class="chip">${s.author}</span>` : ""}
|
||||
${allowEdit ? `<button class="chip icon" data-edit="${s.id}" type="button" title="${t("card.edit")}">✏️</button>` : ""}
|
||||
${allowDelete ? `<button class="chip icon danger-chip" data-delete="${s.id}" type="button" title="${t("card.delete")}">✕</button>` : ""}
|
||||
@@ -681,6 +694,63 @@ function renderAdminVoteStatus() {
|
||||
status.className = ready ? "badge" : "badge warning";
|
||||
}
|
||||
|
||||
function renderAdminLinker() {
|
||||
const wrap = $("admin-linker");
|
||||
const source = $("link-source");
|
||||
const target = $("link-target");
|
||||
if (!wrap || !source || !target) return;
|
||||
|
||||
const visible = state.me?.isAdmin && state.phase === "Vote";
|
||||
wrap.classList.toggle("hidden", !visible);
|
||||
if (!visible) return;
|
||||
|
||||
const previousSource = source.value;
|
||||
const previousTarget = target.value;
|
||||
|
||||
const options = (state.allSuggestions ?? []).slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const fillSelect = (select, placeholderKey) => {
|
||||
select.innerHTML = "";
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.textContent = t(placeholderKey);
|
||||
placeholder.disabled = true;
|
||||
placeholder.selected = true;
|
||||
select.appendChild(placeholder);
|
||||
|
||||
options.forEach((s) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = s.id;
|
||||
opt.textContent = buildLinkOptionLabel(s);
|
||||
opt.dataset.root = linkRootId(s);
|
||||
select.appendChild(opt);
|
||||
});
|
||||
};
|
||||
|
||||
fillSelect(source, "admin.linkSourcePlaceholder");
|
||||
fillSelect(target, "admin.linkTargetPlaceholder");
|
||||
|
||||
if (previousSource && options.some((s) => String(s.id) === previousSource)) source.value = previousSource;
|
||||
if (previousTarget && options.some((s) => String(s.id) === previousTarget)) target.value = previousTarget;
|
||||
|
||||
const preventSameSelection = () => {
|
||||
const sourceVal = source.value;
|
||||
const targetVal = target.value;
|
||||
Array.from(target.options).forEach((opt) => {
|
||||
if (!opt.value) return;
|
||||
opt.disabled = opt.value === sourceVal;
|
||||
});
|
||||
Array.from(source.options).forEach((opt) => {
|
||||
if (!opt.value) return;
|
||||
opt.disabled = opt.value === targetVal;
|
||||
});
|
||||
};
|
||||
|
||||
source.onchange = preventSameSelection;
|
||||
target.onchange = preventSameSelection;
|
||||
preventSameSelection();
|
||||
}
|
||||
|
||||
function openDeleteConfirmModal(s) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "edit-modal";
|
||||
@@ -754,6 +824,72 @@ function isValidImageUrl(url) {
|
||||
}
|
||||
}
|
||||
|
||||
function linkRootId(s) {
|
||||
return s?.parentSuggestionId ?? s?.id;
|
||||
}
|
||||
|
||||
function linkedPeerIds(s) {
|
||||
if (!s) return [];
|
||||
if (Array.isArray(s.linkedIds) && s.linkedIds.length > 0) {
|
||||
return s.linkedIds.filter((id) => id !== s.id);
|
||||
}
|
||||
if (!state.allSuggestions?.length) return [];
|
||||
const root = linkRootId(s);
|
||||
return state.allSuggestions
|
||||
.filter((other) => linkRootId(other) === root && other.id !== s.id)
|
||||
.map((other) => other.id);
|
||||
}
|
||||
|
||||
function linkedPeerTitles(s) {
|
||||
if (!s) return [];
|
||||
if (Array.isArray(s.linkedTitles) && s.linkedTitles.length > 0) {
|
||||
return s.linkedTitles;
|
||||
}
|
||||
if (!state.allSuggestions?.length) return [];
|
||||
const root = linkRootId(s);
|
||||
return state.allSuggestions
|
||||
.filter((other) => linkRootId(other) === root && other.id !== s.id)
|
||||
.map((other) => other.name);
|
||||
}
|
||||
|
||||
function isLinked(s) {
|
||||
return !!s?.parentSuggestionId || linkedPeerIds(s).length > 0;
|
||||
}
|
||||
|
||||
function linkTooltip(s) {
|
||||
const peers = linkedPeerTitles(s);
|
||||
if (peers.length === 0) return t("card.linked");
|
||||
return t("card.linkedWith", { names: peers.join(", ") });
|
||||
}
|
||||
|
||||
function renderLinkBadge(s) {
|
||||
if (!isLinked(s)) return "";
|
||||
return `<span class="chip icon link-chip" title="${linkTooltip(s)}">🔗</span>`;
|
||||
}
|
||||
|
||||
function buildLinkOptionLabel(s) {
|
||||
const author = s.author ? ` — ${s.author}` : "";
|
||||
const linked = isLinked(s) ? " 🔗" : "";
|
||||
return `${s.name}${author}${linked}`;
|
||||
}
|
||||
|
||||
function syncLinkedSliders(sourceEl, value) {
|
||||
const linkedAttr = sourceEl?.dataset?.linked;
|
||||
if (!linkedAttr) return;
|
||||
const ids = linkedAttr.split(",").filter(Boolean);
|
||||
ids.forEach((id) => {
|
||||
const slider = document.querySelector(`input[type=range][data-id="${id}"]`);
|
||||
if (!slider || slider === sourceEl) return;
|
||||
slider.value = value;
|
||||
const scoreLabel = $("score-" + id);
|
||||
if (scoreLabel) scoreLabel.textContent = value;
|
||||
const emojiEl = $("emoji-" + id);
|
||||
if (emojiEl) emojiEl.textContent = scoreToEmoji(Number(value));
|
||||
const warn = $("warn-" + id);
|
||||
if (warn) warn.classList.add("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePhaseNav() {
|
||||
const isAdmin = !!state.me?.isAdmin;
|
||||
const phase = state.phase;
|
||||
@@ -791,6 +927,7 @@ export function updatePhaseNav() {
|
||||
}
|
||||
|
||||
renderAdminVoteStatus();
|
||||
renderAdminLinker();
|
||||
|
||||
// Toggle admin-only back buttons
|
||||
const backButtons = ["nav-vote-prev"];
|
||||
|
||||
Reference in New Issue
Block a user