diff --git a/API.md b/API.md index 424be66..a89cc7d 100644 --- a/API.md +++ b/API.md @@ -20,6 +20,7 @@ POST /api/me/name GET /api/suggestions/mine POST /api/suggestions DELETE /api/suggestions/{id} +PUT /api/suggestions/{id} (non-admin: own suggestion, Suggest phase only; admin: any time, any suggestion) GET /api/suggestions/all ## Votes (requires auth + phase gating) diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs index 5d6af5f..2db51c0 100644 --- a/Endpoints/SuggestEndpoints.cs +++ b/Endpoints/SuggestEndpoints.cs @@ -99,6 +99,55 @@ public static class SuggestEndpoints 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) => { var phase = await EndpointHelpers.GetPhase(db); diff --git a/wwwroot/app.js b/wwwroot/app.js index 1156ba8..1231e83 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -140,14 +140,16 @@ function renderMySuggestions() { const wrap = $("my-suggestions"); if (!wrap) return; 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() { const list = $("all-suggestions"); if (!list) return; 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() { @@ -156,7 +158,7 @@ function renderVotes() { list.innerHTML = ""; const votesMap = Object.fromEntries(state.myVotes.map((v) => [v.suggestionId, v.score])); 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 footer = document.createElement("div"); 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"); card.className = "game-card"; const hasImage = !!s.screenshotUrl; @@ -379,6 +381,7 @@ function buildCard(s, { showAuthor = false, allowDelete = false }) { ${s.gameUrl ? `Site ↗` : ""} ${s.youtubeUrl ? `YouTube ↗` : ""} ${showAuthor && s.author ? `${s.author}` : ""} + ${allowEdit ? `` : ""} ${allowDelete ? `` : ""} @@ -390,6 +393,10 @@ function buildCard(s, { showAuthor = false, allowDelete = false }) { const btn = card.querySelector(".card-visual"); btn.addEventListener("click", () => openLightbox(s.screenshotUrl, s.name)); } + if (allowEdit) { + const editBtn = card.querySelector("[data-edit]"); + editBtn?.addEventListener("click", () => openEditModal(s)); + } if (allowDelete) { const del = card.querySelector("[data-delete]"); del.addEventListener("click", async () => { @@ -405,6 +412,61 @@ function buildCard(s, { showAuthor = false, allowDelete = false }) { return card; } +function openEditModal(s) { + const overlay = document.createElement("div"); + overlay.className = "edit-modal"; + overlay.innerHTML = ` +