Add linked suggestions with synced voting

This commit is contained in:
2026-02-05 09:07:46 +01:00
parent 431370ceb9
commit 5d432c9d17
19 changed files with 725 additions and 34 deletions

View File

@@ -2,8 +2,9 @@ namespace GameList.Contracts;
public record SetNameRequest(string Name); 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 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 VoteRequest(int SuggestionId, int Score);
public record ResultsOpenRequest(bool ResultsOpen); public record ResultsOpenRequest(bool ResultsOpen);
public record VoteFinalizeRequest(bool Final); public record VoteFinalizeRequest(bool Final);
public record VoteStatusDto(Guid PlayerId, string Name, bool Finalized); public record VoteStatusDto(Guid PlayerId, string Name, bool Finalized);
public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId);

View File

@@ -49,6 +49,11 @@ public class AppDbContext : DbContext
builder.Property(s => s.GameUrl).HasMaxLength(2048); builder.Property(s => s.GameUrl).HasMaxLength(2048);
builder.Property(s => s.MinPlayers); builder.Property(s => s.MinPlayers);
builder.Property(s => s.MaxPlayers); 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 => modelBuilder.Entity<Vote>(builder =>

View 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
}
}
}

View 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");
}
}
}

View File

@@ -131,6 +131,9 @@ namespace GameList.Data.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("ParentSuggestionId")
.HasColumnType("INTEGER");
b.Property<Guid>("PlayerId") b.Property<Guid>("PlayerId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -144,6 +147,8 @@ namespace GameList.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ParentSuggestionId");
b.HasIndex("PlayerId"); b.HasIndex("PlayerId");
b.ToTable("Suggestions"); b.ToTable("Suggestions");
@@ -179,12 +184,19 @@ namespace GameList.Data.Migrations
modelBuilder.Entity("GameList.Domain.Suggestion", b => 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") b.HasOne("GameList.Domain.Player", "Player")
.WithMany("Suggestions") .WithMany("Suggestions")
.HasForeignKey("PlayerId") .HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("ParentSuggestion");
b.Navigation("Player"); b.Navigation("Player");
}); });
@@ -216,6 +228,8 @@ namespace GameList.Data.Migrations
modelBuilder.Entity("GameList.Domain.Suggestion", b => modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{ {
b.Navigation("LinkedSuggestions");
b.Navigation("Votes"); b.Navigation("Votes");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618

View File

@@ -34,5 +34,9 @@ public class Suggestion
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; 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>(); public ICollection<Vote> Votes { get; set; } = new List<Vote>();
} }

View File

@@ -3,6 +3,7 @@ using GameList.Domain;
using GameList.Contracts; using GameList.Contracts;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
namespace GameList.Endpoints; namespace GameList.Endpoints;
@@ -51,6 +52,78 @@ public static class AdminEndpoints
return Results.Ok(new { voters, ready, waiting }); 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) => admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
{ {
if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized(); if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized();

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using GameList.Data; using GameList.Data;
using GameList.Domain; using GameList.Domain;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -163,4 +164,35 @@ internal static class EndpointHelpers
ResultsOpen = false, ResultsOpen = false,
UpdatedAt = DateTimeOffset.UnixEpoch 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();
}
} }

View File

@@ -46,11 +46,47 @@ public static class ResultsEndpoints
s.GameUrl, s.GameUrl,
s.Description, s.Description,
s.Genre, s.Genre,
s.ParentSuggestionId
}) })
.OrderByDescending(r => r.Average) .OrderByDescending(r => r.Average)
.ToListAsync(); .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);
} }
); );
} }

View File

@@ -28,13 +28,14 @@ public static class SuggestEndpoints
s.GameUrl, s.GameUrl,
s.CreatedAt, s.CreatedAt,
s.MinPlayers, s.MinPlayers,
s.MaxPlayers s.MaxPlayers,
s.ParentSuggestionId
}) })
.ToListAsync(); .ToListAsync();
var ordered = mine var ordered = mine
.OrderBy(s => s.CreatedAt) .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); return Results.Ok(ordered);
}); });
@@ -206,13 +207,23 @@ public static class SuggestEndpoints
s.MinPlayers, s.MinPlayers,
s.MaxPlayers, s.MaxPlayers,
Author = s.Player!.DisplayName, Author = s.Player!.DisplayName,
s.CreatedAt s.CreatedAt,
s.ParentSuggestionId
}) })
.ToListAsync(); .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 var ordered = all
.OrderBy(s => s.CreatedAt) .OrderBy(s => s.CreatedAt)
.Select(s => new .Select(s =>
{
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex)
.Where(id => id != s.Id)
.ToList();
return new
{ {
s.Id, s.Id,
s.PlayerId, s.PlayerId,
@@ -224,7 +235,14 @@ public static class SuggestEndpoints
s.GameUrl, s.GameUrl,
s.MinPlayers, s.MinPlayers,
s.MaxPlayers, s.MaxPlayers,
s.Author s.Author,
s.ParentSuggestionId,
LinkedIds = linkedIds,
LinkedTitles = linkedIds
.Where(id => nameLookup.ContainsKey(id))
.Select(id => nameLookup[id])
.ToList()
};
}); });
return Results.Ok(ordered); return Results.Ok(ordered);

View File

@@ -41,28 +41,40 @@ public static class VoteEndpoints
if (string.IsNullOrWhiteSpace(player.DisplayName)) if (string.IsNullOrWhiteSpace(player.DisplayName))
return Results.BadRequest(new { error = "Set a display name before voting." }); return Results.BadRequest(new { error = "Set a display name before voting." });
var suggestionExists = await db.Suggestions.AnyAsync(s => s.Id == request.SuggestionId); var linkMap = await db.Suggestions.AsNoTracking()
if (!suggestionExists) .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." }); 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); var existingVotes = await db.Votes
.Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId))
.ToListAsync();
foreach (var suggestionId in linkedIds)
{
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == suggestionId);
if (vote == null) if (vote == null)
{ {
vote = new Vote db.Votes.Add(new Vote
{ {
PlayerId = player.Id, PlayerId = player.Id,
SuggestionId = request.SuggestionId, SuggestionId = suggestionId,
Score = request.Score Score = request.Score
}; });
db.Votes.Add(vote);
} }
else else
{ {
vote.Score = request.Score; vote.Score = request.Score;
} }
}
await db.SaveChangesAsync(); 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) => app.MapPost("/api/votes/finalize", async ([FromBody] VoteFinalizeRequest request, HttpContext ctx, AppDbContext db) =>

View File

@@ -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) { async function adminAction(fn, successMessage) {

View File

@@ -29,3 +29,11 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
#admin-linker select {
width: 100%;
padding: 8px;
border-radius: 8px;
border: 1px solid #e3d4bd;
background: #fffaf3;
}

View File

@@ -99,6 +99,11 @@ button .chip {
width: 30px; width: 30px;
font-size: 18px; font-size: 18px;
} }
.chip.link-chip {
background: #d7e7ff;
border: 1px solid #b9d1ff;
color: #1b3d75;
}
.chip.danger-chip { .chip.danger-chip {
background: #e0564f; background: #e0564f;
border: 1px solid #c54740; border: 1px solid #c54740;

View File

@@ -151,6 +151,19 @@
<input type="checkbox" id="results-open" /> <input type="checkbox" id="results-open" />
<span data-i18n="admin.resultsOpenToggle">Allow results phase</span> <span data-i18n="admin.resultsOpenToggle">Allow results phase</span>
</label> </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"> <div class="stack horizontal">
<button id="reset" class="danger" data-i18n="admin.reset">Reset (keep players)</button> <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> <button id="factory-reset" class="danger" data-i18n="admin.factoryReset">Factory reset</button>

View File

@@ -56,4 +56,6 @@ export const adminApi = {
voteStatus: () => request("/api/admin/vote-status"), voteStatus: () => request("/api/admin/vote-status"),
reset: () => request("/api/admin/reset", { method: "POST" }), reset: () => request("/api/admin/reset", { method: "POST" }),
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }), factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
}; };

View File

@@ -91,6 +91,7 @@ export function signatureSuggestions(list) {
s.gameUrl, s.gameUrl,
s.minPlayers, s.minPlayers,
s.maxPlayers, s.maxPlayers,
s.parentSuggestionId,
]), ]),
); );
} }

View File

@@ -75,6 +75,8 @@ const translations = {
"card.site": "Site&nbsp;↗", "card.site": "Site&nbsp;↗",
"card.youtube": "YouTube&nbsp;↗", "card.youtube": "YouTube&nbsp;↗",
"card.openScreenshot": "Open screenshot", "card.openScreenshot": "Open screenshot",
"card.linked": "Votes linked",
"card.linkedWith": "Linked with: {names}",
"vote.saved": "Saved vote", "vote.saved": "Saved vote",
"vote.missing": "Missing", "vote.missing": "Missing",
@@ -107,6 +109,15 @@ const translations = {
"admin.factoryResetDone": "Factory reset complete", "admin.factoryResetDone": "Factory reset complete",
"admin.readyForResults": "Ready for results", "admin.readyForResults": "Ready for results",
"admin.waitingForPlayers": "Waiting for players: {names}", "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.unexpected": "Unexpected error",
"toast.registered": "Registered", "toast.registered": "Registered",
@@ -204,6 +215,8 @@ const translations = {
"card.site": "Webseite&nbsp;↗", "card.site": "Webseite&nbsp;↗",
"card.youtube": "YouTube&nbsp;↗", "card.youtube": "YouTube&nbsp;↗",
"card.openScreenshot": "Screenshot öffnen", "card.openScreenshot": "Screenshot öffnen",
"card.linked": "Verknüpfte Stimmen",
"card.linkedWith": "Verknüpft mit: {names}",
"vote.saved": "Stimme gespeichert", "vote.saved": "Stimme gespeichert",
"vote.missing": "Fehlt", "vote.missing": "Fehlt",
@@ -236,6 +249,15 @@ const translations = {
"admin.factoryResetDone": "Werkseinstellung abgeschlossen", "admin.factoryResetDone": "Werkseinstellung abgeschlossen",
"admin.readyForResults": "Bereit für Ergebnisse", "admin.readyForResults": "Bereit für Ergebnisse",
"admin.waitingForPlayers": "Warten auf: {names}", "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.unexpected": "Unerwarteter Fehler",
"toast.registered": "Registriert", "toast.registered": "Registriert",

View File

@@ -115,6 +115,7 @@ export function renderMySuggestions() {
} }
export function renderAllSuggestions() { export function renderAllSuggestions() {
renderAdminLinker();
const list = $("all-suggestions"); const list = $("all-suggestions");
if (!list) return; if (!list) return;
list.innerHTML = ""; list.innerHTML = "";
@@ -147,12 +148,14 @@ export function renderVotes() {
const current = hasVote ? votesMap[s.id] : 5; // start neutral when no prior vote const current = hasVote ? votesMap[s.id] : 5; // start neutral when no prior vote
const displayScore = hasVote ? current : "—"; const displayScore = hasVote ? current : "—";
const displayEmoji = hasVote ? scoreToEmoji(current) : "⚠️"; const displayEmoji = hasVote ? scoreToEmoji(current) : "⚠️";
const linkedIds = linkedPeerIds(s);
const rootId = linkRootId(s);
const footer = document.createElement("div"); const footer = document.createElement("div");
footer.className = "vote-controls"; footer.className = "vote-controls";
footer.innerHTML = ` footer.innerHTML = `
<div class="warning-text ${hasVote ? "hidden" : ""}" id="warn-${s.id}">${state.votesFinal ? t("vote.missingFinalWarn") : t("vote.missingWarn")}</div> <div class="warning-text ${hasVote ? "hidden" : ""}" id="warn-${s.id}">${state.votesFinal ? t("vote.missingFinalWarn") : t("vote.missingWarn")}</div>
<div class="vote-row"> <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" id="score-${s.id}">${displayScore}</span>
<span class="score-emoji" id="emoji-${s.id}">${displayEmoji}</span> <span class="score-emoji" id="emoji-${s.id}">${displayEmoji}</span>
</div>`; </div>`;
@@ -169,6 +172,7 @@ export function renderVotes() {
if (emojiEl) emojiEl.textContent = scoreToEmoji(val); if (emojiEl) emojiEl.textContent = scoreToEmoji(val);
const warn = $("warn-" + e.target.dataset.id); const warn = $("warn-" + e.target.dataset.id);
if (warn) warn.classList.add("hidden"); if (warn) warn.classList.add("hidden");
syncLinkedSliders(e.target, val);
}); });
input.addEventListener("change", async (e) => { input.addEventListener("change", async (e) => {
if (state.votesFinal) return; if (state.votesFinal) return;
@@ -244,7 +248,7 @@ export function renderResults() {
<td class="game-cell"> <td class="game-cell">
${r.screenshotUrl ? `<img class="thumb clickable-thumb" src="${r.screenshotUrl}" alt="${r.name}">` : ''} ${r.screenshotUrl ? `<img class="thumb clickable-thumb" src="${r.screenshotUrl}" alt="${r.name}">` : ''}
<div class="game-meta"> <div class="game-meta">
<div class="title-line">${r.name}</div> <div class="title-line">${r.name} ${renderLinkBadge(r)}</div>
${buildResultMeta(r)} ${buildResultMeta(r)}
</div> </div>
</td> </td>
@@ -296,6 +300,14 @@ export function buildCard(
const card = document.createElement("article"); const card = document.createElement("article");
card.className = "game-card"; card.className = "game-card";
const hasImage = !!s.screenshotUrl; 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 const visual = hasImage
? `<button class="card-visual" data-img="${s.screenshotUrl}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${s.screenshotUrl}')"></button>` ? `<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>`; : `<div class="card-visual"></div>`;
@@ -320,6 +332,7 @@ export function buildCard(
<div class="card-title-row"> <div class="card-title-row">
<h3 class="card-title" title="${s.name}">${s.name}</h3> <h3 class="card-title" title="${s.name}">${s.name}</h3>
<div class="title-meta"> <div class="title-meta">
${linkChip}
${showAuthor && s.author ? `<span class="chip">${s.author}</span>` : ""} ${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>` : ""} ${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>` : ""} ${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"; 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) { function openDeleteConfirmModal(s) {
const overlay = document.createElement("div"); const overlay = document.createElement("div");
overlay.className = "edit-modal"; 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() { export function updatePhaseNav() {
const isAdmin = !!state.me?.isAdmin; const isAdmin = !!state.me?.isAdmin;
const phase = state.phase; const phase = state.phase;
@@ -791,6 +927,7 @@ export function updatePhaseNav() {
} }
renderAdminVoteStatus(); renderAdminVoteStatus();
renderAdminLinker();
// Toggle admin-only back buttons // Toggle admin-only back buttons
const backButtons = ["nav-vote-prev"]; const backButtons = ["nav-vote-prev"];