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 = ` +
+
+

Edit game

+ +
+
+
+ + + +
+ + + +
+
+ + +
+
+
+
+ `; + + 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) { const overlay = document.createElement("div"); overlay.className = "lightbox"; diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index cff61b9..1f4001a 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -38,6 +38,7 @@ export const api = { mySuggestions: () => request("/api/suggestions/mine"), createSuggestion: (payload) => request("/api/suggestions", { method: "POST", body: payload }), deleteSuggestion: (id) => request(`/api/suggestions/${id}`, { method: "DELETE" }), + updateSuggestion: (id, payload) => request(`/api/suggestions/${id}`, { method: "PUT", body: payload }), allSuggestions: () => request("/api/suggestions/all"), myVotes: () => request("/api/votes/mine"), diff --git a/wwwroot/styles.css b/wwwroot/styles.css index 290e0af..d734877 100644 --- a/wwwroot/styles.css +++ b/wwwroot/styles.css @@ -305,6 +305,38 @@ input[type="range"].full-slider::-moz-range-track { 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 { display: flex; justify-content: space-between;