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
|
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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user