Add fullscreen suggestion edit UX with admin overrides
This commit is contained in:
1
API.md
1
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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ? `<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>` : ""}
|
||||
${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>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 = `
|
||||
<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) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "lightbox";
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user