diff --git a/Contracts/Dtos.cs b/Contracts/Dtos.cs index 7783edd..505b36f 100644 --- a/Contracts/Dtos.cs +++ b/Contracts/Dtos.cs @@ -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? LinkedIds = null, IReadOnlyList? 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); diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 5e4e5f6..e3c884f 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -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(builder => diff --git a/Data/Migrations/20260205075456_AddSuggestionLinks.Designer.cs b/Data/Migrations/20260205075456_AddSuggestionLinks.Designer.cs new file mode 100644 index 0000000..a1c25a4 --- /dev/null +++ b/Data/Migrations/20260205075456_AddSuggestionLinks.Designer.cs @@ -0,0 +1,241 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ResultsOpen") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentPhase") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("DisplayName") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("VotesFinal") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("GameUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Genre") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("MinPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ParentSuggestionId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("ScreenshotUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("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 + } + } +} diff --git a/Data/Migrations/20260205075456_AddSuggestionLinks.cs b/Data/Migrations/20260205075456_AddSuggestionLinks.cs new file mode 100644 index 0000000..8b2ddf4 --- /dev/null +++ b/Data/Migrations/20260205075456_AddSuggestionLinks.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GameList.Data.Migrations +{ + /// + public partial class AddSuggestionLinks : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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); + } + + /// + 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"); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index 46e77d6..ba9c16b 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -131,6 +131,9 @@ namespace GameList.Data.Migrations .HasMaxLength(100) .HasColumnType("TEXT"); + b.Property("ParentSuggestionId") + .HasColumnType("INTEGER"); + b.Property("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 diff --git a/Domain/Suggestion.cs b/Domain/Suggestion.cs index 1083aa3..b465ae6 100644 --- a/Domain/Suggestion.cs +++ b/Domain/Suggestion.cs @@ -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 LinkedSuggestions { get; set; } = new List(); + public ICollection Votes { get; set; } = new List(); } diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index 8aa9545..288c044 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -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 { 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(); diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs index 4f3a02a..ff0dc2e 100644 --- a/Endpoints/EndpointHelpers.cs +++ b/Endpoints/EndpointHelpers.cs @@ -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 BuildLinkRoots(IEnumerable<(int Id, int? ParentId)> items) + { + var parentMap = items.ToDictionary(x => x.Id, x => x.ParentId); + var roots = new Dictionary(); + foreach (var id in parentMap.Keys) + { + roots[id] = FindRootId(id, parentMap); + } + return roots; + } + + public static int FindRootId(int suggestionId, IReadOnlyDictionary parentMap) + { + var current = suggestionId; + var visited = new HashSet(); + + while (parentMap.TryGetValue(current, out var parent) && parent is int p && !visited.Contains(p)) + { + visited.Add(current); + current = p; + } + + return current; + } + + public static List LinkedIdsFor(int suggestionId, IReadOnlyDictionary rootIndex) + { + if (!rootIndex.TryGetValue(suggestionId, out var root)) return new(); + return rootIndex.Where(kv => kv.Value == root).Select(kv => kv.Key).ToList(); + } } diff --git a/Endpoints/ResultsEndpoints.cs b/Endpoints/ResultsEndpoints.cs index fe99d02..a43d2a2 100644 --- a/Endpoints/ResultsEndpoints.cs +++ b/Endpoints/ResultsEndpoints.cs @@ -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); } ); } diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs index a99076d..fd45a24 100644 --- a/Endpoints/SuggestEndpoints.cs +++ b/Endpoints/SuggestEndpoints.cs @@ -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); diff --git a/Endpoints/VoteEndpoints.cs b/Endpoints/VoteEndpoints.cs index c1a9b5d..b93bbc1 100644 --- a/Endpoints/VoteEndpoints.cs +++ b/Endpoints/VoteEndpoints.cs @@ -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) => diff --git a/wwwroot/app.js b/wwwroot/app.js index 3c1107c..8efb488 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -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) { diff --git a/wwwroot/css/admin.css b/wwwroot/css/admin.css index 982eb6d..f4b3991 100644 --- a/wwwroot/css/admin.css +++ b/wwwroot/css/admin.css @@ -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; +} diff --git a/wwwroot/css/components.css b/wwwroot/css/components.css index fbd83b3..56513d1 100644 --- a/wwwroot/css/components.css +++ b/wwwroot/css/components.css @@ -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; diff --git a/wwwroot/index.html b/wwwroot/index.html index 4817345..56970c2 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -151,6 +151,19 @@ Allow results phase +
diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 3ce0343..2d10aa3 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -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 } }), }; diff --git a/wwwroot/js/data.js b/wwwroot/js/data.js index 9f0edaa..c80b1a7 100644 --- a/wwwroot/js/data.js +++ b/wwwroot/js/data.js @@ -91,6 +91,7 @@ export function signatureSuggestions(list) { s.gameUrl, s.minPlayers, s.maxPlayers, + s.parentSuggestionId, ]), ); } diff --git a/wwwroot/js/i18n.js b/wwwroot/js/i18n.js index bb8b49c..a2c81dd 100644 --- a/wwwroot/js/i18n.js +++ b/wwwroot/js/i18n.js @@ -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", diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index 9cf8c4f..018f23d 100644 --- a/wwwroot/js/ui.js +++ b/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 = `
${state.votesFinal ? t("vote.missingFinalWarn") : t("vote.missingWarn")}
- + ${displayScore} ${displayEmoji}
`; @@ -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() { ${r.screenshotUrl ? `${r.name}` : ''}
-
${r.name}
+
${r.name} ${renderLinkBadge(r)}
${buildResultMeta(r)}
@@ -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 ? `🔗` : ""; const visual = hasImage ? `` : `
`; @@ -320,6 +332,7 @@ export function buildCard(

${s.name}

+ ${linkChip} ${showAuthor && s.author ? `${s.author}` : ""} ${allowEdit ? `` : ""} ${allowDelete ? `` : ""} @@ -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 `🔗`; +} + +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"];