Add fullscreen suggestion edit UX with admin overrides

This commit is contained in:
2026-01-29 01:28:39 +01:00
parent 1ba7fd5c37
commit 637451b485
5 changed files with 149 additions and 4 deletions

1
API.md
View File

@@ -20,6 +20,7 @@ POST /api/me/name
GET /api/suggestions/mine GET /api/suggestions/mine
POST /api/suggestions POST /api/suggestions
DELETE /api/suggestions/{id} DELETE /api/suggestions/{id}
PUT /api/suggestions/{id} (non-admin: own suggestion, Suggest phase only; admin: any time, any suggestion)
GET /api/suggestions/all GET /api/suggestions/all
## Votes (requires auth + phase gating) ## Votes (requires auth + phase gating)

View File

@@ -99,6 +99,55 @@ public static class SuggestEndpoints
return Results.NoContent(); return Results.NoContent();
}); });
app.MapPut("/api/suggestions/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
{
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db, config);
if (!isAdmin)
{
if (player is null) return Results.Unauthorized();
var phase = await EndpointHelpers.GetPhase(db);
if (phase != Phase.Suggest)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
}
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100)
{
return Results.BadRequest(new { error = "Name is required and must be <= 100 characters." });
}
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id);
if (suggestion == null) return Results.NotFound(new { error = "Suggestion not found." });
if (!isAdmin)
{
if (suggestion.PlayerId != player!.Id)
return Results.Unauthorized();
}
suggestion.Name = request.Name.Trim();
suggestion.Genre = EndpointHelpers.TrimTo(request.Genre, 50);
suggestion.Description = EndpointHelpers.TrimTo(request.Description, 500);
suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048);
suggestion.YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048);
suggestion.GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048);
await db.SaveChangesAsync();
return Results.Ok(new
{
suggestion.Id,
suggestion.Name,
suggestion.Genre,
suggestion.Description,
suggestion.ScreenshotUrl,
suggestion.YoutubeUrl,
suggestion.GameUrl
});
});
app.MapGet("/api/suggestions/all", async (HttpContext ctx, AppDbContext db) => app.MapGet("/api/suggestions/all", async (HttpContext ctx, AppDbContext db) =>
{ {
var phase = await EndpointHelpers.GetPhase(db); var phase = await EndpointHelpers.GetPhase(db);

View File

@@ -140,14 +140,16 @@ function renderMySuggestions() {
const wrap = $("my-suggestions"); const wrap = $("my-suggestions");
if (!wrap) return; if (!wrap) return;
wrap.innerHTML = ""; wrap.innerHTML = "";
state.mySuggestions.forEach((s) => wrap.appendChild(buildCard(s, { showAuthor: false, allowDelete: true }))); const allowEdit = state.phase === "Suggest" || state.me?.isAdmin;
state.mySuggestions.forEach((s) => wrap.appendChild(buildCard(s, { showAuthor: false, allowDelete: true, allowEdit })));
} }
function renderAllSuggestions() { function renderAllSuggestions() {
const list = $("all-suggestions"); const list = $("all-suggestions");
if (!list) return; if (!list) return;
list.innerHTML = ""; list.innerHTML = "";
state.allSuggestions.forEach((s) => list.appendChild(buildCard(s, { showAuthor: true }))); const allowEdit = !!state.me?.isAdmin;
state.allSuggestions.forEach((s) => list.appendChild(buildCard(s, { showAuthor: true, allowEdit })));
} }
function renderVotes() { function renderVotes() {
@@ -156,7 +158,7 @@ function renderVotes() {
list.innerHTML = ""; list.innerHTML = "";
const votesMap = Object.fromEntries(state.myVotes.map((v) => [v.suggestionId, v.score])); const votesMap = Object.fromEntries(state.myVotes.map((v) => [v.suggestionId, v.score]));
state.allSuggestions.forEach((s) => { state.allSuggestions.forEach((s) => {
const li = buildCard(s, { showAuthor: true }); const li = buildCard(s, { showAuthor: true, allowEdit: !!state.me?.isAdmin });
const current = votesMap[s.id] ?? 0; const current = votesMap[s.id] ?? 0;
const footer = document.createElement("div"); const footer = document.createElement("div");
footer.className = "vote-controls"; footer.className = "vote-controls";
@@ -363,7 +365,7 @@ async function refreshPhaseData() {
} }
} }
function buildCard(s, { showAuthor = false, allowDelete = false }) { function buildCard(s, { showAuthor = false, allowDelete = false, allowEdit = false }) {
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;
@@ -379,6 +381,7 @@ function buildCard(s, { showAuthor = false, allowDelete = false }) {
${s.gameUrl ? `<a class="link compact" href="${s.gameUrl}" target="_blank" rel="noopener">Site ↗</a>` : ""} ${s.gameUrl ? `<a class="link compact" href="${s.gameUrl}" target="_blank" rel="noopener">Site ↗</a>` : ""}
${s.youtubeUrl ? `<a class="link compact" href="${s.youtubeUrl}" target="_blank" rel="noopener">YouTube ↗</a>` : ""} ${s.youtubeUrl ? `<a class="link compact" href="${s.youtubeUrl}" target="_blank" rel="noopener">YouTube ↗</a>` : ""}
${showAuthor && s.author ? `<span class="chip">${s.author}</span>` : ""} ${showAuthor && s.author ? `<span class="chip">${s.author}</span>` : ""}
${allowEdit ? `<button class="chip" data-edit="${s.id}" type="button">Edit</button>` : ""}
${allowDelete ? `<button class="chip danger-chip" data-delete="${s.id}" type="button">Delete</button>` : ""} ${allowDelete ? `<button class="chip danger-chip" data-delete="${s.id}" type="button">Delete</button>` : ""}
</div> </div>
</div> </div>
@@ -390,6 +393,10 @@ function buildCard(s, { showAuthor = false, allowDelete = false }) {
const btn = card.querySelector(".card-visual"); const btn = card.querySelector(".card-visual");
btn.addEventListener("click", () => openLightbox(s.screenshotUrl, s.name)); btn.addEventListener("click", () => openLightbox(s.screenshotUrl, s.name));
} }
if (allowEdit) {
const editBtn = card.querySelector("[data-edit]");
editBtn?.addEventListener("click", () => openEditModal(s));
}
if (allowDelete) { if (allowDelete) {
const del = card.querySelector("[data-delete]"); const del = card.querySelector("[data-delete]");
del.addEventListener("click", async () => { del.addEventListener("click", async () => {
@@ -405,6 +412,61 @@ function buildCard(s, { showAuthor = false, allowDelete = false }) {
return card; return card;
} }
function openEditModal(s) {
const overlay = document.createElement("div");
overlay.className = "edit-modal";
overlay.innerHTML = `
<div class="edit-panel">
<div class="edit-header">
<h3>Edit game</h3>
<button class="lightbox-close" aria-label="Close">×</button>
</div>
<div class="edit-body">
<form class="stack" id="edit-form">
<input name="name" required maxlength="100" placeholder="Game name *" value="${s.name ?? ""}" />
<input name="genre" maxlength="50" placeholder="Genre" value="${s.genre ?? ""}" />
<textarea name="description" maxlength="500" placeholder="Short description">${s.description ?? ""}</textarea>
<div class="stack horizontal">
<input name="screenshotUrl" maxlength="2048" placeholder="Screenshot URL" value="${s.screenshotUrl ?? ""}" />
<input name="youtubeUrl" maxlength="2048" placeholder="YouTube URL" value="${s.youtubeUrl ?? ""}" />
<input name="gameUrl" maxlength="2048" placeholder="Game website URL" value="${s.gameUrl ?? ""}" />
</div>
<div class="stack horizontal">
<button type="submit">Save changes</button>
<button type="button" class="ghost" id="edit-cancel">Cancel</button>
</div>
</form>
</div>
</div>
`;
const close = () => overlay.remove();
overlay.addEventListener("click", (e) => {
if (e.target.classList.contains("edit-modal") || e.target.classList.contains("lightbox-close")) close();
});
const cancelBtn = overlay.querySelector("#edit-cancel");
cancelBtn?.addEventListener("click", close);
const form = overlay.querySelector("#edit-form");
form?.addEventListener("submit", async (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(form).entries());
if (!data.name?.trim()) return toast("Name required", true);
try {
await api.updateSuggestion(s.id, data);
toast("Saved changes");
close();
await refreshPhaseData();
} catch (err) {
if (handleAuthError(err)) return;
toast(err.message, true);
}
});
document.body.appendChild(overlay);
}
function openLightbox(url, title) { function openLightbox(url, title) {
const overlay = document.createElement("div"); const overlay = document.createElement("div");
overlay.className = "lightbox"; overlay.className = "lightbox";

View File

@@ -38,6 +38,7 @@ export const api = {
mySuggestions: () => request("/api/suggestions/mine"), mySuggestions: () => request("/api/suggestions/mine"),
createSuggestion: (payload) => request("/api/suggestions", { method: "POST", body: payload }), createSuggestion: (payload) => request("/api/suggestions", { method: "POST", body: payload }),
deleteSuggestion: (id) => request(`/api/suggestions/${id}`, { method: "DELETE" }), deleteSuggestion: (id) => request(`/api/suggestions/${id}`, { method: "DELETE" }),
updateSuggestion: (id, payload) => request(`/api/suggestions/${id}`, { method: "PUT", body: payload }),
allSuggestions: () => request("/api/suggestions/all"), allSuggestions: () => request("/api/suggestions/all"),
myVotes: () => request("/api/votes/mine"), myVotes: () => request("/api/votes/mine"),

View File

@@ -305,6 +305,38 @@ input[type="range"].full-slider::-moz-range-track {
cursor: pointer; cursor: pointer;
} }
.edit-modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 110;
}
.edit-modal .edit-panel {
background: #0b1224;
border: 1px solid #1f2937;
border-radius: 12px;
width: min(960px, 94vw);
max-height: 92vh;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: 0 20px 48px rgba(0,0,0,0.45);
}
.edit-modal .edit-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.edit-modal .edit-body {
overflow: auto;
max-height: 70vh;
}
.panel-header { .panel-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;