Add joker support during voting
This commit is contained in:
@@ -6,6 +6,7 @@ public record SuggestionDto(int Id, string Name, string? Genre, string? Descript
|
||||
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 VoteStatusDto(Guid PlayerId, string Name, bool Finalized, bool HasJoker);
|
||||
public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId);
|
||||
public record UnlinkSuggestionsRequest(int SuggestionId);
|
||||
public record GrantJokerRequest(Guid PlayerId);
|
||||
|
||||
@@ -26,6 +26,7 @@ public class AppDbContext : DbContext
|
||||
builder.Property(p => p.PasswordHash).IsRequired();
|
||||
builder.Property(p => p.PasswordSalt).IsRequired();
|
||||
builder.Property(p => p.IsAdmin).HasDefaultValue(false);
|
||||
builder.Property(p => p.HasJoker).HasDefaultValue(false);
|
||||
builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest);
|
||||
builder.Property(p => p.VotesFinal).HasDefaultValue(false);
|
||||
builder.HasMany(p => p.Suggestions)
|
||||
|
||||
246
Data/Migrations/20260205120525_AddPlayerJoker.Designer.cs
generated
Normal file
246
Data/Migrations/20260205120525_AddPlayerJoker.Designer.cs
generated
Normal file
@@ -0,0 +1,246 @@
|
||||
// <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("20260205120525_AddPlayerJoker")]
|
||||
partial class AddPlayerJoker
|
||||
{
|
||||
/// <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>("HasJoker")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
29
Data/Migrations/20260205120525_AddPlayerJoker.cs
Normal file
29
Data/Migrations/20260205120525_AddPlayerJoker.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GameList.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPlayerJoker : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HasJoker",
|
||||
table: "Players",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HasJoker",
|
||||
table: "Players");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,11 @@ namespace GameList.Data.Migrations
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("HasJoker")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
|
||||
@@ -22,6 +22,7 @@ public class Player
|
||||
public bool IsAdmin { get; set; }
|
||||
public Phase CurrentPhase { get; set; } = Phase.Suggest;
|
||||
public bool VotesFinal { get; set; }
|
||||
public bool HasJoker { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ public static class AdminEndpoints
|
||||
.AsNoTracking()
|
||||
.Where(p => p.CurrentPhase == Phase.Vote || p.Suggestions.Any())
|
||||
.OrderBy(p => p.DisplayName ?? p.Username)
|
||||
.Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.VotesFinal))
|
||||
.Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.VotesFinal, p.HasJoker))
|
||||
.ToListAsync();
|
||||
|
||||
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
|
||||
@@ -52,6 +52,23 @@ public static class AdminEndpoints
|
||||
return Results.Ok(new { voters, ready, waiting });
|
||||
});
|
||||
|
||||
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
{
|
||||
if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized();
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == request.PlayerId);
|
||||
if (player is null) return Results.NotFound(new { error = "Player not found." });
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return Results.BadRequest(new { error = "Player must be in the Vote phase to receive a joker." });
|
||||
|
||||
player.HasJoker = true;
|
||||
player.VotesFinal = false;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { player.Id, player.HasJoker });
|
||||
});
|
||||
|
||||
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
@@ -187,7 +204,8 @@ public static class AdminEndpoints
|
||||
await db.Suggestions.ExecuteDeleteAsync();
|
||||
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest)
|
||||
.SetProperty(x => x.VotesFinal, false));
|
||||
.SetProperty(x => x.VotesFinal, false)
|
||||
.SetProperty(x => x.HasJoker, false));
|
||||
var state = await db.AppState.FirstAsync();
|
||||
state.ResultsOpen = false;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
@@ -21,6 +21,7 @@ public static class StateEndpoints
|
||||
{
|
||||
CurrentPhase = phase,
|
||||
player.VotesFinal,
|
||||
player.HasJoker,
|
||||
state.ResultsOpen,
|
||||
state.UpdatedAt,
|
||||
Players = await db.Players.CountAsync(),
|
||||
@@ -35,7 +36,7 @@ public static class StateEndpoints
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
return Results.Ok(new { player.Id, player.DisplayName, player.Username, player.IsAdmin, CurrentPhase = phase, player.VotesFinal });
|
||||
return Results.Ok(new { player.Id, player.DisplayName, player.Username, player.IsAdmin, CurrentPhase = phase, player.VotesFinal, player.HasJoker });
|
||||
});
|
||||
|
||||
app.MapPost("/api/me/phase/next", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
|
||||
@@ -62,7 +62,8 @@ public static class SuggestEndpoints
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Suggest)
|
||||
var usingJoker = phase == Phase.Vote && player.HasJoker;
|
||||
if (phase != Phase.Suggest && !usingJoker)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||
@@ -90,6 +91,13 @@ public static class SuggestEndpoints
|
||||
};
|
||||
|
||||
db.Suggestions.Add(suggestion);
|
||||
|
||||
if (usingJoker)
|
||||
{
|
||||
player.HasJoker = false;
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id });
|
||||
|
||||
@@ -124,6 +124,14 @@ function setupHandlers() {
|
||||
openNewSuggestionModal();
|
||||
});
|
||||
}
|
||||
const openJokerBtn = $("open-joker-modal");
|
||||
if (openJokerBtn) {
|
||||
openJokerBtn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (state.phase !== "Vote" || !state.hasJoker) return;
|
||||
openNewSuggestionModal();
|
||||
});
|
||||
}
|
||||
|
||||
bindNavButtons();
|
||||
|
||||
@@ -195,6 +203,21 @@ function setupHandlers() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const grantJokerBtn = $("grant-joker");
|
||||
if (grantJokerBtn) {
|
||||
grantJokerBtn.addEventListener("click", async () => {
|
||||
const playerId = $("joker-player")?.value;
|
||||
if (!playerId) return toast(t("admin.jokerSelectFirst"), true);
|
||||
try {
|
||||
await adminApi.grantJoker(playerId);
|
||||
toast(t("admin.jokerGranted"));
|
||||
await refreshPhaseData();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function adminAction(fn, successMessage) {
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
<div id="vote-view" class="phase-view hidden">
|
||||
<div class="phase-header">
|
||||
<h2 id="vote-title" data-i18n="section.vote">Vote 0–10</h2>
|
||||
<button id="open-joker-modal" class="ghost hidden" data-i18n="suggest.jokerAddButton">Use joker: suggest a game</button>
|
||||
</div>
|
||||
<div id="vote-list" class="card-grid"></div>
|
||||
<div class="card subcard phase-nav" id="nav-vote">
|
||||
@@ -151,6 +152,15 @@
|
||||
<input type="checkbox" id="results-open" />
|
||||
<span data-i18n="admin.resultsOpenToggle">Allow results phase</span>
|
||||
</label>
|
||||
<div class="stack hidden" id="admin-joker">
|
||||
<h4 data-i18n="admin.jokerTitle">Jokers</h4>
|
||||
<p class="muted small" data-i18n="admin.jokerHint">Grant a player one extra suggestion slot during voting.</p>
|
||||
<label class="stack">
|
||||
<span class="label" data-i18n="admin.jokerSelect">Player</span>
|
||||
<select id="joker-player"></select>
|
||||
</label>
|
||||
<button id="grant-joker" class="secondary" type="button" data-i18n="admin.jokerGive">Grant joker</button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -56,6 +56,7 @@ export const adminApi = {
|
||||
voteStatus: () => request("/api/admin/vote-status"),
|
||||
reset: () => request("/api/admin/reset", { method: "POST" }),
|
||||
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
|
||||
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
|
||||
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
|
||||
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
|
||||
unlinkSuggestions: (suggestionId) =>
|
||||
|
||||
@@ -6,6 +6,7 @@ export async function loadState() {
|
||||
const [me, stateData] = await Promise.all([api.me(), api.state()]);
|
||||
state.isAuthenticated = true;
|
||||
state.me = me;
|
||||
state.hasJoker = me.hasJoker ?? false;
|
||||
state.prevPhase = state.phase;
|
||||
state.phase = stateData.currentPhase;
|
||||
state.resultsOpen = stateData.resultsOpen;
|
||||
|
||||
@@ -44,6 +44,7 @@ const translations = {
|
||||
"suggest.title": "Suggest games (up to 5)",
|
||||
"suggest.new": "Add new suggestion",
|
||||
"suggest.addButton": "Suggest a game",
|
||||
"suggest.jokerAddButton": "Use joker: suggest a game",
|
||||
"suggest.hint": "Only you can see your suggestions until voting starts.",
|
||||
"form.gameName": "Game name *",
|
||||
"form.genre": "Genre",
|
||||
@@ -109,6 +110,13 @@ const translations = {
|
||||
"admin.factoryResetDone": "Factory reset complete",
|
||||
"admin.readyForResults": "Ready for results",
|
||||
"admin.waitingForPlayers": "Waiting for players: {names}",
|
||||
"admin.jokerTitle": "Jokers",
|
||||
"admin.jokerHint": "Grant a player one extra suggestion during voting.",
|
||||
"admin.jokerSelect": "Player",
|
||||
"admin.jokerGive": "Grant joker",
|
||||
"admin.jokerGranted": "Joker granted",
|
||||
"admin.jokerSelectFirst": "Pick a player first.",
|
||||
"admin.jokerPlaceholder": "Pick a player",
|
||||
"admin.linkTitle": "Link games",
|
||||
"admin.linkHint": "Use during voting to merge duplicates. Linking clears votes and unfinalizes voters.",
|
||||
"admin.linkSource": "Game to link",
|
||||
@@ -189,6 +197,7 @@ const translations = {
|
||||
"suggest.title": "Schlage Spiele vor (bis zu 5)",
|
||||
"suggest.new": "Neuen Vorschlag hinzufügen",
|
||||
"suggest.addButton": "Spiel vorschlagen",
|
||||
"suggest.jokerAddButton": "Joker nutzen: Spiel vorschlagen",
|
||||
"suggest.hint": "Nur du siehst deine Vorschläge bis zum Start der Abstimmung.",
|
||||
"form.gameName": "Spielname *",
|
||||
"form.genre": "Genre",
|
||||
@@ -254,6 +263,13 @@ const translations = {
|
||||
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
|
||||
"admin.readyForResults": "Bereit für Ergebnisse",
|
||||
"admin.waitingForPlayers": "Warten auf: {names}",
|
||||
"admin.jokerTitle": "Joker",
|
||||
"admin.jokerHint": "Gib einem Spieler einen Joker für einen zusätzlichen Vorschlag in der Bewertungsphase.",
|
||||
"admin.jokerSelect": "Spieler",
|
||||
"admin.jokerGive": "Joker vergeben",
|
||||
"admin.jokerGranted": "Joker vergeben",
|
||||
"admin.jokerSelectFirst": "Wähle zuerst einen Spieler.",
|
||||
"admin.jokerPlaceholder": "Spieler wählen",
|
||||
"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",
|
||||
|
||||
@@ -6,6 +6,7 @@ export const state = {
|
||||
prevPhase: null,
|
||||
resultsOpen: false,
|
||||
votesFinal: false,
|
||||
hasJoker: false,
|
||||
counts: null,
|
||||
mySuggestions: [],
|
||||
allSuggestions: [],
|
||||
@@ -22,6 +23,7 @@ export function clearUserState() {
|
||||
state.prevPhase = null;
|
||||
state.resultsOpen = false;
|
||||
state.votesFinal = false;
|
||||
state.hasJoker = false;
|
||||
state.counts = null;
|
||||
state.mySuggestions = [];
|
||||
state.allSuggestions = [];
|
||||
|
||||
@@ -565,11 +565,16 @@ export function openNewSuggestionModal() {
|
||||
submitLabel: t("form.submit"),
|
||||
initial: {},
|
||||
onSubmit: async (data, close, submitBtn) => {
|
||||
const wasVotePhase = state.phase === "Vote";
|
||||
await api.createSuggestion(data);
|
||||
toast(t("toast.suggestionAdded"));
|
||||
if (submitBtn) triggerCelebration(submitBtn);
|
||||
close();
|
||||
await window.loadSuggestData();
|
||||
if (wasVotePhase) {
|
||||
await window.refreshPhaseData();
|
||||
} else {
|
||||
await window.loadSuggestData();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -712,13 +717,16 @@ function renderAdminVoteStatus() {
|
||||
if (!state.me?.isAdmin) return;
|
||||
const list = $("admin-voter-list");
|
||||
const status = $("admin-ready-status");
|
||||
const jokerWrap = $("admin-joker");
|
||||
const jokerSelect = $("joker-player");
|
||||
if (!state.adminVoteStatus || !list || !status) return;
|
||||
|
||||
list.innerHTML = "";
|
||||
state.adminVoteStatus.voters.forEach((v) => {
|
||||
const li = document.createElement("li");
|
||||
const name = v.name?.length > 24 ? `${v.name.slice(0, 21)}…` : v.name;
|
||||
li.textContent = `${name} — ${v.finalized ? "✅" : "⏳"}`;
|
||||
const jokerMark = v.hasJoker ? " 🎟" : "";
|
||||
li.textContent = `${name}${jokerMark} — ${v.finalized ? "✅" : "⏳"}`;
|
||||
li.title = v.name;
|
||||
list.appendChild(li);
|
||||
});
|
||||
@@ -732,6 +740,29 @@ function renderAdminVoteStatus() {
|
||||
? t("admin.readyForResults")
|
||||
: t("admin.waitingForPlayers", { names: waitingDisplay.join(", ") });
|
||||
status.className = ready ? "badge" : "badge warning";
|
||||
|
||||
if (jokerWrap) jokerWrap.classList.toggle("hidden", state.phase !== "Vote");
|
||||
if (jokerSelect && state.phase === "Vote") {
|
||||
const previous = jokerSelect.value;
|
||||
jokerSelect.innerHTML = "";
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.disabled = true;
|
||||
placeholder.selected = true;
|
||||
placeholder.textContent = t("admin.jokerPlaceholder");
|
||||
jokerSelect.appendChild(placeholder);
|
||||
|
||||
state.adminVoteStatus.voters.forEach((v) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = v.playerId;
|
||||
opt.textContent = v.hasJoker ? `${v.name} — 🎟` : v.name;
|
||||
jokerSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
if (previous && Array.from(jokerSelect.options).some((o) => o.value === previous)) {
|
||||
jokerSelect.value = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderAdminLinker() {
|
||||
@@ -961,6 +992,12 @@ export function updatePhaseNav() {
|
||||
|
||||
showNav("nav-suggest", phase === "Suggest");
|
||||
showNav("nav-vote", phase === "Vote");
|
||||
const jokerBtn = $("open-joker-modal");
|
||||
if (jokerBtn) {
|
||||
const showJoker = phase === "Vote" && state.hasJoker;
|
||||
jokerBtn.classList.toggle("hidden", !showJoker);
|
||||
jokerBtn.disabled = !showJoker;
|
||||
}
|
||||
|
||||
const finalizeBtn = $("finalize-votes");
|
||||
if (finalizeBtn) {
|
||||
|
||||
Reference in New Issue
Block a user