Move suggest form into modal
This commit is contained in:
@@ -2,7 +2,6 @@ import { api, adminApi } from "./js/api.js";
|
|||||||
import { t, setLanguage, getLanguage, initI18n, onLanguageChange } from "./js/i18n.js";
|
import { t, setLanguage, getLanguage, initI18n, onLanguageChange } from "./js/i18n.js";
|
||||||
import { state, clearUserState, getSavedUsername, setSavedUsername } from "./js/state.js";
|
import { state, clearUserState, getSavedUsername, setSavedUsername } from "./js/state.js";
|
||||||
import { $, toast } from "./js/dom.js";
|
import { $, toast } from "./js/dom.js";
|
||||||
import { triggerCelebration } from "./js/effects.js";
|
|
||||||
import {
|
import {
|
||||||
setAuthUI,
|
setAuthUI,
|
||||||
setAuthMode,
|
setAuthMode,
|
||||||
@@ -16,7 +15,7 @@ import {
|
|||||||
syncVoteScores,
|
syncVoteScores,
|
||||||
renderResults,
|
renderResults,
|
||||||
renderPhaseTitles,
|
renderPhaseTitles,
|
||||||
normalizeSuggestionForm,
|
openNewSuggestionModal,
|
||||||
} from "./js/ui.js";
|
} from "./js/ui.js";
|
||||||
import {
|
import {
|
||||||
loadState,
|
loadState,
|
||||||
@@ -117,24 +116,14 @@ function setupHandlers() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$("suggest-form").addEventListener("submit", async (e) => {
|
const openSuggestBtn = $("open-suggest-modal");
|
||||||
e.preventDefault();
|
if (openSuggestBtn) {
|
||||||
const form = e.target;
|
openSuggestBtn.addEventListener("click", (e) => {
|
||||||
const data = normalizeSuggestionForm(new FormData(form));
|
e.preventDefault();
|
||||||
if (!data.name) return toast(t("toast.nameRequired"), true);
|
if (state.phase !== "Suggest") return;
|
||||||
if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) {
|
openNewSuggestionModal();
|
||||||
return toast(t("toast.invalidImageUrl"), true);
|
});
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
await api.createSuggestion(data);
|
|
||||||
form.reset();
|
|
||||||
toast(t("toast.suggestionAdded"));
|
|
||||||
triggerCelebration(form.querySelector("button[type=submit]"));
|
|
||||||
await loadSuggestData();
|
|
||||||
} catch (err) {
|
|
||||||
toast(err.message, true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$("set-phase").addEventListener("click", async () => {
|
$("set-phase").addEventListener("click", async () => {
|
||||||
const phase = $("phase-select").value;
|
const phase = $("phase-select").value;
|
||||||
@@ -223,16 +212,3 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|
||||||
function isValidImageUrl(url) {
|
|
||||||
if (!url) return true;
|
|
||||||
try {
|
|
||||||
const u = new URL(url);
|
|
||||||
const allowed = ["http:", "https:"];
|
|
||||||
if (!allowed.includes(u.protocol)) return false;
|
|
||||||
const path = u.pathname.toLowerCase();
|
|
||||||
return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif"].some((ext) => path.endsWith(ext));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -86,57 +86,15 @@
|
|||||||
<section id="actions-card">
|
<section id="actions-card">
|
||||||
<div id="suggest-view" class="phase-view hidden">
|
<div id="suggest-view" class="phase-view hidden">
|
||||||
<div class="phase-header">
|
<div class="phase-header">
|
||||||
<h2 data-i18n="suggest.title">Suggest (up to 5)</h2>
|
<div class="phase-text">
|
||||||
<p class="hint" data-i18n="suggest.hint">Only you can see your suggestions until Reveal.</p>
|
<h2 data-i18n="suggest.title">Suggest (up to 5)</h2>
|
||||||
|
<p class="hint" data-i18n="suggest.hint">Only you can see your suggestions until Reveal.</p>
|
||||||
|
</div>
|
||||||
|
<button id="open-suggest-modal" class="ghost" data-i18n="suggest.addButton">Suggest a game</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="suggest-grid">
|
<div class="card subcard">
|
||||||
<div class="card">
|
<h3 data-i18n="section.mySuggestions">Your suggestions</h3>
|
||||||
<h3 data-i18n="suggest.new">Add new suggestion</h2>
|
<div id="my-suggestions" class="card-grid"></div>
|
||||||
<form id="suggest-form" class="stack">
|
|
||||||
<label class="stack">
|
|
||||||
<span class="label" data-i18n="form.gameName">Game name *</span>
|
|
||||||
<input name="name" required maxlength="100" />
|
|
||||||
</label>
|
|
||||||
<label class="stack">
|
|
||||||
<span class="label" data-i18n="form.genre">Genre</span>
|
|
||||||
<input name="genre" maxlength="50" />
|
|
||||||
</label>
|
|
||||||
<label class="stack">
|
|
||||||
<span class="label" data-i18n="form.description">Description</span>
|
|
||||||
<textarea name="description" maxlength="500"></textarea>
|
|
||||||
</label>
|
|
||||||
<div class="stack">
|
|
||||||
<span class="label" data-i18n="form.players">Players</span>
|
|
||||||
<div class="stack horizontal">
|
|
||||||
<label class="stack">
|
|
||||||
<span class="label" data-i18n="form.min">Min</span>
|
|
||||||
<input name="minPlayers" type="number" min="1" max="32" inputmode="numeric" />
|
|
||||||
</label>
|
|
||||||
<label class="stack">
|
|
||||||
<span class="label" data-i18n="form.max">Max</span>
|
|
||||||
<input name="maxPlayers" type="number" min="1" max="32" inputmode="numeric" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label class="stack">
|
|
||||||
<span class="label" data-i18n="form.screenshot">Screenshot URL</span>
|
|
||||||
<input name="screenshotUrl" maxlength="2048" />
|
|
||||||
</label>
|
|
||||||
<label class="stack">
|
|
||||||
<span class="label" data-i18n="form.youtube">YouTube URL</span>
|
|
||||||
<input name="youtubeUrl" maxlength="2048" />
|
|
||||||
</label>
|
|
||||||
<label class="stack">
|
|
||||||
<span class="label" data-i18n="form.gameUrl">Game website URL</span>
|
|
||||||
<input name="gameUrl" maxlength="2048" />
|
|
||||||
</label>
|
|
||||||
<button type="submit" data-i18n="form.submit">Submit</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="card subcard">
|
|
||||||
<h3 data-i18n="section.mySuggestions">Your suggestions</h3>
|
|
||||||
<div id="my-suggestions" class="card-grid"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const translations = {
|
|||||||
|
|
||||||
"suggest.title": "Suggest games (up to 5)",
|
"suggest.title": "Suggest games (up to 5)",
|
||||||
"suggest.new": "Add new suggestion",
|
"suggest.new": "Add new suggestion",
|
||||||
|
"suggest.addButton": "Suggest a game",
|
||||||
"suggest.hint": "Only you can see your suggestions until Reveal.",
|
"suggest.hint": "Only you can see your suggestions until Reveal.",
|
||||||
"form.gameName": "Game name *",
|
"form.gameName": "Game name *",
|
||||||
"form.genre": "Genre",
|
"form.genre": "Genre",
|
||||||
@@ -97,6 +98,7 @@ const translations = {
|
|||||||
"toast.invalidImageUrl": "Screenshot URL must be http(s) and end with an image file.",
|
"toast.invalidImageUrl": "Screenshot URL must be http(s) and end with an image file.",
|
||||||
|
|
||||||
"modal.editTitle": "Edit game",
|
"modal.editTitle": "Edit game",
|
||||||
|
"modal.addTitle": "Suggest a game",
|
||||||
"modal.save": "Save changes",
|
"modal.save": "Save changes",
|
||||||
"modal.cancel": "Cancel",
|
"modal.cancel": "Cancel",
|
||||||
"modal.close": "Close",
|
"modal.close": "Close",
|
||||||
@@ -137,6 +139,7 @@ const translations = {
|
|||||||
|
|
||||||
"suggest.title": "Schlage Spiele vor (bis zu 5)",
|
"suggest.title": "Schlage Spiele vor (bis zu 5)",
|
||||||
"suggest.new": "Neuen Vorschlag hinzufügen",
|
"suggest.new": "Neuen Vorschlag hinzufügen",
|
||||||
|
"suggest.addButton": "Spiel vorschlagen",
|
||||||
"suggest.hint": "Nur du siehst deine Vorschläge bis zur Enthüllung.",
|
"suggest.hint": "Nur du siehst deine Vorschläge bis zur Enthüllung.",
|
||||||
"form.gameName": "Spielname *",
|
"form.gameName": "Spielname *",
|
||||||
"form.genre": "Genre",
|
"form.genre": "Genre",
|
||||||
@@ -201,6 +204,7 @@ const translations = {
|
|||||||
"toast.invalidImageUrl": "Screenshot-URL muss mit http(s) beginnen und auf eine Bilddatei enden.",
|
"toast.invalidImageUrl": "Screenshot-URL muss mit http(s) beginnen und auf eine Bilddatei enden.",
|
||||||
|
|
||||||
"modal.editTitle": "Spiel bearbeiten",
|
"modal.editTitle": "Spiel bearbeiten",
|
||||||
|
"modal.addTitle": "Spiel vorschlagen",
|
||||||
"modal.save": "Änderungen speichern",
|
"modal.save": "Änderungen speichern",
|
||||||
"modal.cancel": "Abbrechen",
|
"modal.cancel": "Abbrechen",
|
||||||
"modal.close": "Schließen",
|
"modal.close": "Schließen",
|
||||||
|
|||||||
177
wwwroot/js/ui.js
177
wwwroot/js/ui.js
@@ -338,7 +338,19 @@ export function buildCard(
|
|||||||
}
|
}
|
||||||
if (allowEdit) {
|
if (allowEdit) {
|
||||||
const editBtn = card.querySelector("[data-edit]");
|
const editBtn = card.querySelector("[data-edit]");
|
||||||
editBtn?.addEventListener("click", () => openEditModal(s));
|
editBtn?.addEventListener("click", () =>
|
||||||
|
openSuggestionModal({
|
||||||
|
title: t("modal.editTitle"),
|
||||||
|
submitLabel: t("modal.save"),
|
||||||
|
initial: s,
|
||||||
|
onSubmit: async (data, close) => {
|
||||||
|
await api.updateSuggestion(s.id, data);
|
||||||
|
toast(t("toast.savedChanges"));
|
||||||
|
close();
|
||||||
|
await window.refreshPhaseData();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (allowDelete) {
|
if (allowDelete) {
|
||||||
const del = card.querySelector("[data-delete]");
|
const del = card.querySelector("[data-delete]");
|
||||||
@@ -355,62 +367,90 @@ export function buildCard(
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditModal(s) {
|
function buildSuggestionForm(initial = {}) {
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.className = "stack suggestion-form";
|
||||||
|
form.innerHTML = `
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label" data-i18n="form.gameName">${t("form.gameName")}</span>
|
||||||
|
<input name="name" required maxlength="100" />
|
||||||
|
</label>
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label" data-i18n="form.genre">${t("form.genre")}</span>
|
||||||
|
<input name="genre" maxlength="50" />
|
||||||
|
</label>
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label" data-i18n="form.description">${t("form.description")}</span>
|
||||||
|
<textarea name="description" maxlength="500"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="stack">
|
||||||
|
<span class="label">${t("form.players")}</span>
|
||||||
|
<div class="stack horizontal">
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label">${t("form.min")}</span>
|
||||||
|
<input name="minPlayers" type="number" min="1" max="32" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label">${t("form.max")}</span>
|
||||||
|
<input name="maxPlayers" type="number" min="1" max="32" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label" data-i18n="form.screenshot">${t("form.screenshot")}</span>
|
||||||
|
<input name="screenshotUrl" maxlength="2048" />
|
||||||
|
</label>
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label" data-i18n="form.youtube">${t("form.youtube")}</span>
|
||||||
|
<input name="youtubeUrl" maxlength="2048" />
|
||||||
|
</label>
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label" data-i18n="form.gameUrl">${t("form.gameUrl")}</span>
|
||||||
|
<input name="gameUrl" maxlength="2048" />
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const setVal = (name, value) => {
|
||||||
|
const input = form.querySelector(`[name="${name}"]`);
|
||||||
|
if (input) input.value = value ?? "";
|
||||||
|
};
|
||||||
|
setVal("name", initial.name ?? "");
|
||||||
|
setVal("genre", initial.genre ?? "");
|
||||||
|
const desc = form.querySelector("textarea[name=description]");
|
||||||
|
if (desc) desc.value = initial.description ?? "";
|
||||||
|
setVal("minPlayers", initial.minPlayers ?? "");
|
||||||
|
setVal("maxPlayers", initial.maxPlayers ?? "");
|
||||||
|
setVal("screenshotUrl", initial.screenshotUrl ?? "");
|
||||||
|
setVal("youtubeUrl", initial.youtubeUrl ?? "");
|
||||||
|
setVal("gameUrl", initial.gameUrl ?? "");
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSuggestionModal({ title, submitLabel, initial = {}, onSubmit }) {
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
overlay.className = "edit-modal";
|
overlay.className = "edit-modal";
|
||||||
overlay.innerHTML = `
|
const panel = document.createElement("div");
|
||||||
<div class="edit-panel">
|
panel.className = "edit-panel";
|
||||||
|
panel.innerHTML = `
|
||||||
<div class="edit-header">
|
<div class="edit-header">
|
||||||
<h3>${t("modal.editTitle")}</h3>
|
<h3>${title}</h3>
|
||||||
<button class="lightbox-close" aria-label="${t("modal.close")}">×</button>
|
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="edit-body">
|
<div class="edit-body"></div>
|
||||||
<form class="stack" id="edit-form">
|
`;
|
||||||
<label class="stack">
|
|
||||||
<span class="label" data-i18n="form.gameName">${t("form.gameName")}</span>
|
const form = buildSuggestionForm(initial);
|
||||||
<input name="name" required maxlength="100" value="${s.name ?? ""}" />
|
const actions = document.createElement("div");
|
||||||
</label>
|
actions.className = "stack horizontal";
|
||||||
<label class="stack">
|
const submitBtn = document.createElement("button");
|
||||||
<span class="label" data-i18n="form.genre">${t("form.genre")}</span>
|
submitBtn.type = "submit";
|
||||||
<input name="genre" maxlength="50" value="${s.genre ?? ""}" />
|
submitBtn.textContent = submitLabel;
|
||||||
</label>
|
const cancelBtn = document.createElement("button");
|
||||||
<label class="stack">
|
cancelBtn.type = "button";
|
||||||
<span class="label" data-i18n="form.description">${t("form.description")}</span>
|
cancelBtn.className = "ghost";
|
||||||
<textarea name="description" maxlength="500">${s.description ?? ""}</textarea>
|
cancelBtn.textContent = t("modal.cancel");
|
||||||
</label>
|
actions.append(submitBtn, cancelBtn);
|
||||||
<div class="stack">
|
form.appendChild(actions);
|
||||||
<span class="label">${t("form.players")}</span>
|
|
||||||
<div class="stack horizontal">
|
|
||||||
<label class="stack">
|
|
||||||
<span class="label">${t("form.min")}</span>
|
|
||||||
<input name="minPlayers" type="number" min="1" max="32" inputmode="numeric" value="${s.minPlayers ?? ""}" />
|
|
||||||
</label>
|
|
||||||
<label class="stack">
|
|
||||||
<span class="label">${t("form.max")}</span>
|
|
||||||
<input name="maxPlayers" type="number" min="1" max="32" inputmode="numeric" value="${s.maxPlayers ?? ""}" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label class="stack">
|
|
||||||
<span class="label" data-i18n="form.screenshot">${t("form.screenshot")}</span>
|
|
||||||
<input name="screenshotUrl" maxlength="2048" value="${s.screenshotUrl ?? ""}" />
|
|
||||||
</label>
|
|
||||||
<label class="stack">
|
|
||||||
<span class="label" data-i18n="form.youtube">${t("form.youtube")}</span>
|
|
||||||
<input name="youtubeUrl" maxlength="2048" value="${s.youtubeUrl ?? ""}" />
|
|
||||||
</label>
|
|
||||||
<label class="stack">
|
|
||||||
<span class="label" data-i18n="form.gameUrl">${t("form.gameUrl")}</span>
|
|
||||||
<input name="gameUrl" maxlength="2048" value="${s.gameUrl ?? ""}" />
|
|
||||||
</label>
|
|
||||||
<div class="stack horizontal">
|
|
||||||
<button type="submit">${t("modal.save")}</button>
|
|
||||||
<button type="button" class="ghost" id="edit-cancel">${t("modal.cancel")}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const close = () => overlay.remove();
|
const close = () => overlay.remove();
|
||||||
overlay.addEventListener("click", (e) => {
|
overlay.addEventListener("click", (e) => {
|
||||||
@@ -420,12 +460,9 @@ function openEditModal(s) {
|
|||||||
)
|
)
|
||||||
close();
|
close();
|
||||||
});
|
});
|
||||||
|
cancelBtn.addEventListener("click", close);
|
||||||
|
|
||||||
const cancelBtn = overlay.querySelector("#edit-cancel");
|
form.addEventListener("submit", async (e) => {
|
||||||
cancelBtn?.addEventListener("click", close);
|
|
||||||
|
|
||||||
const form = overlay.querySelector("#edit-form");
|
|
||||||
form?.addEventListener("submit", async (e) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const data = normalizeSuggestionForm(new FormData(form));
|
const data = normalizeSuggestionForm(new FormData(form));
|
||||||
if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) {
|
if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) {
|
||||||
@@ -433,17 +470,31 @@ function openEditModal(s) {
|
|||||||
}
|
}
|
||||||
if (!data.name?.trim()) return toast(t("toast.nameRequired"), true);
|
if (!data.name?.trim()) return toast(t("toast.nameRequired"), true);
|
||||||
try {
|
try {
|
||||||
await api.updateSuggestion(s.id, data);
|
await onSubmit(data, close);
|
||||||
toast(t("toast.savedChanges"));
|
|
||||||
close();
|
|
||||||
await window.refreshPhaseData();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (window.handleAuthError(err)) return;
|
if (window.handleAuthError?.(err)) return;
|
||||||
toast(err.message, true);
|
toast(err.message, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
panel.querySelector(".edit-body")?.appendChild(form);
|
||||||
|
overlay.appendChild(panel);
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openNewSuggestionModal() {
|
||||||
|
openSuggestionModal({
|
||||||
|
title: t("modal.addTitle") || t("suggest.new"),
|
||||||
|
submitLabel: t("form.submit"),
|
||||||
|
initial: {},
|
||||||
|
onSubmit: async (data, close) => {
|
||||||
|
await api.createSuggestion(data);
|
||||||
|
toast(t("toast.suggestionAdded"));
|
||||||
|
close();
|
||||||
|
await window.loadSuggestData();
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openLightbox(url, title) {
|
export function openLightbox(url, title) {
|
||||||
|
|||||||
@@ -122,6 +122,11 @@ html {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
.phase-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
.phase-header h2 {
|
.phase-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user