Seperate language translation in diffrent file and sort files

This commit is contained in:
2026-04-09 10:34:09 +02:00
parent 045d4bf575
commit bcf18812eb
14 changed files with 570 additions and 439 deletions
+161 -159
View File
@@ -1,9 +1,13 @@
// Board configuration
const DEFAULT_BOARD_SIZE = 8; const DEFAULT_BOARD_SIZE = 8;
const MIN_BOARD_SIZE = 4; const MIN_BOARD_SIZE = 4;
const MAX_BOARD_SIZE = 13; const MAX_BOARD_SIZE = 13;
const EMPTY_CELL = -1; const EMPTY_CELL = -1;
const GAME_STORAGE_KEY = "eight-queens-dashboard-session-v1"; const GAME_STORAGE_KEY = "eight-queens-dashboard-session-v1";
const AppLanguage = window.AppLanguage;
// Mutable game and solver state
let mode = "queens"; let mode = "queens";
let boardSize = DEFAULT_BOARD_SIZE; let boardSize = DEFAULT_BOARD_SIZE;
let pendingBoardSize = DEFAULT_BOARD_SIZE; let pendingBoardSize = DEFAULT_BOARD_SIZE;
@@ -22,6 +26,7 @@ let solverLoading = false;
let solverRequestId = 0; let solverRequestId = 0;
let currentSolverHighlight = null; let currentSolverHighlight = null;
// Cached DOM references
const boardDiv = document.getElementById("board"); const boardDiv = document.getElementById("board");
const statusMessage = document.getElementById("statusMessage"); const statusMessage = document.getElementById("statusMessage");
const queenList = document.getElementById("queenList"); const queenList = document.getElementById("queenList");
@@ -78,7 +83,9 @@ const timeHistoryTableBody = document.getElementById("timeHistoryTableBody");
const infoModal = document.getElementById("infoModal"); const infoModal = document.getElementById("infoModal");
const infoModalBackdrop = document.getElementById("infoModalBackdrop"); const infoModalBackdrop = document.getElementById("infoModalBackdrop");
const SUPPORTED_LANGUAGES = ["de", "en"]; // -----------------------------------------------------------------------------
// Translation dictionaries and language-specific copy
let currentLanguage = "de"; let currentLanguage = "de";
const MODE_COPY = { const MODE_COPY = {
@@ -437,42 +444,8 @@ const translations = {
} }
}; };
function createEmptyBoard(size) { // -----------------------------------------------------------------------------
return Array(size).fill(EMPTY_CELL); // Translation helpers
}
function clampBoardSize(size) {
return Math.min(MAX_BOARD_SIZE, Math.max(MIN_BOARD_SIZE, Number(size)));
}
function getColumnLabel(index) {
return String.fromCharCode(65 + index);
}
function normalizeLanguage(language) {
const shortLanguage = String(language || "").trim().slice(0, 2).toLowerCase();
return SUPPORTED_LANGUAGES.includes(shortLanguage) ? shortLanguage : "de";
}
function getStoredLanguage() {
try {
return window.localStorage.getItem("preferredLanguage");
} catch (error) {
return null;
}
}
function storeLanguage(language) {
try {
window.localStorage.setItem("preferredLanguage", language);
} catch (error) {
return;
}
}
function detectBrowserLanguage() {
return normalizeLanguage(navigator.language || navigator.userLanguage || "de");
}
function t(key, params = {}) { function t(key, params = {}) {
const value = translations[currentLanguage][key]; const value = translations[currentLanguage][key];
@@ -483,54 +456,48 @@ function getDefaultSolverMessage() {
return t("defaultSolverMessage"); return t("defaultSolverMessage");
} }
function getModeCopy(selectedMode = mode, language = currentLanguage) {
return MODE_COPY[language][selectedMode === "rooks" ? "rooks" : "queens"];
}
function getPieceLabel(selectedMode) {
return getModeCopy(selectedMode).singular;
}
function getModeLabel(selectedMode) {
return getModeCopy(selectedMode).plural;
}
function applyTextTranslations() { function applyTextTranslations() {
document.documentElement.lang = currentLanguage; AppLanguage.applyTranslations({
document.body.dataset.language = currentLanguage; translate: (key) => t(key),
allowHtml: (element, translatedText) => element.tagName === "TITLE" || String(translatedText).includes("<br"),
document.querySelectorAll("[data-i18n]").forEach((element) => { attributeMappings: [
const key = element.dataset.i18n; {
const translatedText = t(key); selector: "[data-i18n-placeholder]",
datasetKey: "i18nPlaceholder",
if (translatedText === undefined) { attribute: "placeholder"
return; },
{
selector: "[data-i18n-aria-label]",
datasetKey: "i18nAriaLabel",
attribute: "aria-label"
} }
]
if (element.tagName === "TITLE" || String(translatedText).includes("<br")) {
element.innerHTML = translatedText;
return;
}
element.textContent = translatedText;
});
document.querySelectorAll("[data-i18n-placeholder]").forEach((element) => {
const translatedText = t(element.dataset.i18nPlaceholder);
if (translatedText === undefined) {
return;
}
element.setAttribute("placeholder", translatedText);
});
document.querySelectorAll("[data-i18n-aria-label]").forEach((element) => {
const translatedText = t(element.dataset.i18nAriaLabel);
if (translatedText === undefined) {
return;
}
element.setAttribute("aria-label", translatedText);
}); });
} }
function setLanguage(language) { // A language change also refreshes dynamic dashboard content,
currentLanguage = normalizeLanguage(language); // not just static text nodes in the template.
const languageController = AppLanguage.createController({
button: langSwitch,
onApply(language, helpers) {
currentLanguage = language;
applyTextTranslations(); applyTextTranslations();
helpers.updateLanguageSwitch({
langSwitch.textContent = currentLanguage.toUpperCase(); ariaLabel: t("languageSwitchAria"),
langSwitch.setAttribute("aria-label", t("languageSwitchAria")); title: currentLanguage === "de" ? "Switch to English" : "Zu Deutsch wechseln"
langSwitch.title = currentLanguage === "de" ? "Switch to English" : "Zu Deutsch wechseln"; });
updateSettingsTrigger(); updateSettingsTrigger();
updateModeContent(); updateModeContent();
@@ -543,11 +510,30 @@ function setLanguage(language) {
setSolverMessage(t("solverReady", { modeLabel: getModeLabel(mode) })); setSolverMessage(t("solverReady", { modeLabel: getModeLabel(mode) }));
} }
storeLanguage(currentLanguage); if (helpers.source === "toggle") {
resetSaveForm();
void refreshTimeHistory();
if (savePointsSidebar.classList.contains("open")) {
void refreshSavePoints();
}
}
}
});
// -----------------------------------------------------------------------------
// Core data and formatting helpers
function createEmptyBoard(size) {
return Array(size).fill(EMPTY_CELL);
} }
function getModeCopy(selectedMode = mode, language = currentLanguage) { function clampBoardSize(size) {
return MODE_COPY[language][selectedMode === "rooks" ? "rooks" : "queens"]; return Math.min(MAX_BOARD_SIZE, Math.max(MIN_BOARD_SIZE, Number(size)));
}
function getColumnLabel(index) {
return String.fromCharCode(65 + index);
} }
function sanitizeBoard(candidateBoard, size) { function sanitizeBoard(candidateBoard, size) {
@@ -585,6 +571,7 @@ function getElapsedMilliseconds() {
return elapsedBeforeTimerStartMs + Math.max(0, Date.now() - timerStartedAtMs); return elapsedBeforeTimerStartMs + Math.max(0, Date.now() - timerStartedAtMs);
} }
// Keep a minimal local snapshot so a refresh can restore the current run.
function persistCurrentSession() { function persistCurrentSession() {
try { try {
window.localStorage.setItem( window.localStorage.setItem(
@@ -603,6 +590,9 @@ function persistCurrentSession() {
} }
} }
// -----------------------------------------------------------------------------
// Timer and UI synchronization
function updateProgressDisplay() { function updateProgressDisplay() {
stepCounterValue.textContent = String(stepCount); stepCounterValue.textContent = String(stepCount);
timerValue.textContent = formatDuration(Math.floor(getElapsedMilliseconds() / 1000)); timerValue.textContent = formatDuration(Math.floor(getElapsedMilliseconds() / 1000));
@@ -661,6 +651,8 @@ function ensureFreshRunAfterSolvedBoard() {
resetProgress(); resetProgress();
} }
// These helpers keep the static labels and the dynamic dashboard widgets aligned
// with the currently selected mode, language, and board size.
function updateModeButtons() { function updateModeButtons() {
btnQueens.classList.toggle("active", mode === "queens"); btnQueens.classList.toggle("active", mode === "queens");
btnRooks.classList.toggle("active", mode === "rooks"); btnRooks.classList.toggle("active", mode === "rooks");
@@ -719,6 +711,9 @@ function setSolverMessage(message = getDefaultSolverMessage()) {
solverMessage.textContent = message; solverMessage.textContent = message;
} }
// -----------------------------------------------------------------------------
// Rendering and solver state helpers
function updateSolverSpeed(rawValue) { function updateSolverSpeed(rawValue) {
solverSpeedMs = Number(rawValue); solverSpeedMs = Number(rawValue);
solverSpeedInput.value = String(solverSpeedMs); solverSpeedInput.value = String(solverSpeedMs);
@@ -730,6 +725,8 @@ function updateSolverSpeed(rawValue) {
} }
} }
// These render helpers are reused by manual play, solver playback,
// save-point loading, and session restore.
function updateBoardLayout() { function updateBoardLayout() {
let cellSize = Math.max(32, Math.min(60, Math.floor(480 / boardSize))); let cellSize = Math.max(32, Math.min(60, Math.floor(480 / boardSize)));
let labelSize = cellSize; let labelSize = cellSize;
@@ -746,6 +743,35 @@ function updateBoardLayout() {
boardDiv.style.setProperty("--label-size", `${labelSize}px`); boardDiv.style.setProperty("--label-size", `${labelSize}px`);
} }
function updateQueenList() {
queenList.innerHTML = "";
let count = 1;
for (let row = 0; row < boardSize; row++) {
if (board[row] === EMPTY_CELL) {
continue;
}
const col = board[row];
const position = `${getColumnLabel(col)}${boardSize - row}`;
const li = document.createElement("li");
li.textContent = `${getPieceLabel(mode)} ${count}: ${position}`;
queenList.appendChild(li);
count += 1;
}
const placedPieces = count - 1;
pieceCountBadge.textContent = t("pieceCount", { count: placedPieces });
if (placedPieces === 0) {
const emptyItem = document.createElement("li");
emptyItem.className = "empty";
emptyItem.textContent = t("pieceEmpty", { modeLabel: getModeLabel(mode) });
queenList.appendChild(emptyItem);
}
}
function isSolverPlaying() { function isSolverPlaying() {
return solverTimerId !== null; return solverTimerId !== null;
} }
@@ -816,6 +842,9 @@ function rewindSolverTrace() {
updateSolverControls(); updateSolverControls();
} }
// -----------------------------------------------------------------------------
// Board rules, rendering, and player interaction
function setMode(newMode) { function setMode(newMode) {
if (mode === newMode) { if (mode === newMode) {
return; return;
@@ -831,13 +860,6 @@ function setMode(newMode) {
}); });
} }
function closeAllPanels() {
toggleSidebar(false);
toggleSavePanel(false);
toggleSavePointsPanel(false);
toggleInfoModal(false);
}
function getConflicts(selectedBoard = board, selectedMode = mode) { function getConflicts(selectedBoard = board, selectedMode = mode) {
const conflicts = []; const conflicts = [];
@@ -883,6 +905,8 @@ function finishSolvedRun() {
persistCurrentSession(); persistCurrentSession();
} }
// The board is always rendered from state so manual moves, restored sessions,
// and solver playback all share the same visual path.
function drawBoard() { function drawBoard() {
boardDiv.innerHTML = ""; boardDiv.innerHTML = "";
boardDiv.className = "board-grid"; boardDiv.className = "board-grid";
@@ -962,6 +986,30 @@ function placeQueen(row, col) {
} }
} }
function resetBoard(options = {}) {
const {
statusMessageText = "",
statusColor = "",
solverMessageText = t("solverReady", { modeLabel: getModeLabel(mode) })
} = options;
discardSolverTrace(solverMessageText);
board = createEmptyBoard(boardSize);
currentSolverHighlight = null;
resetProgress();
if (statusMessageText) {
setStatus(statusMessageText, statusColor);
} else {
setStatus();
}
drawBoard();
}
// -----------------------------------------------------------------------------
// Panels and board settings
function previewBoardSize(size) { function previewBoardSize(size) {
pendingBoardSize = clampBoardSize(size); pendingBoardSize = clampBoardSize(size);
sidebarBoardSizeValue.textContent = pendingBoardSize; sidebarBoardSizeValue.textContent = pendingBoardSize;
@@ -1028,10 +1076,6 @@ function toggleInfoModal(forceOpen) {
infoModal.setAttribute("aria-hidden", String(!shouldOpen)); infoModal.setAttribute("aria-hidden", String(!shouldOpen));
} }
function getPieceLabel(selectedMode) {
return getModeCopy(selectedMode).singular;
}
function getPlacedPieceCount() { function getPlacedPieceCount() {
return board.filter((col) => col !== EMPTY_CELL).length; return board.filter((col) => col !== EMPTY_CELL).length;
} }
@@ -1051,6 +1095,16 @@ function openSavePanel() {
toggleSavePanel(true); toggleSavePanel(true);
} }
function closeAllPanels() {
toggleSidebar(false);
toggleSavePanel(false);
toggleSavePointsPanel(false);
toggleInfoModal(false);
}
// -----------------------------------------------------------------------------
// Save point and history helpers
function getBoardPreview(savedBoard, limit = 4) { function getBoardPreview(savedBoard, limit = 4) {
const preview = []; const preview = [];
@@ -1098,10 +1152,6 @@ function createSavePointActionButton(label, className, onClick) {
return button; return button;
} }
function getModeLabel(selectedMode) {
return getModeCopy(selectedMode).plural;
}
function renderSavePoints(savePoints) { function renderSavePoints(savePoints) {
savePointsList.innerHTML = ""; savePointsList.innerHTML = "";
@@ -1211,6 +1261,8 @@ async function openSavePointsPanel() {
toggleSavePointsPanel(true); toggleSavePointsPanel(true);
} }
// Server-loaded save points and locally restored sessions both flow through
// this helper so the dashboard only has one "hydrate state into UI" path.
function applyLoadedGame(data) { function applyLoadedGame(data) {
discardSolverTrace(t("solverReady", { modeLabel: getModeLabel(data.mode || "queens") })); discardSolverTrace(t("solverReady", { modeLabel: getModeLabel(data.mode || "queens") }));
@@ -1235,6 +1287,9 @@ function applyLoadedGame(data) {
persistCurrentSession(); persistCurrentSession();
} }
// -----------------------------------------------------------------------------
// Solver execution
function applySolverStep(step) { function applySolverStep(step) {
board = sanitizeBoard(step.board, boardSize); board = sanitizeBoard(step.board, boardSize);
@@ -1320,6 +1375,8 @@ function startSolverPlayback(stepImmediately = true) {
scheduleNextSolverPlaybackStep(); scheduleNextSolverPlaybackStep();
} }
// requestId prevents older async responses from overwriting a newer solver run
// when the user changes size, mode, or solver action quickly.
async function prepareSolutionTrace() { async function prepareSolutionTrace() {
const endpoint = mode === "rooks" ? "/solve_rooks_trace" : "/solve_trace"; const endpoint = mode === "rooks" ? "/solve_rooks_trace" : "/solve_trace";
const requestId = ++solverRequestId; const requestId = ++solverRequestId;
@@ -1484,55 +1541,8 @@ async function instantSolve() {
} }
} }
function resetBoard(options = {}) { // -----------------------------------------------------------------------------
const { // Save point persistence actions
statusMessageText = "",
statusColor = "",
solverMessageText = t("solverReady", { modeLabel: getModeLabel(mode) })
} = options;
discardSolverTrace(solverMessageText);
board = createEmptyBoard(boardSize);
currentSolverHighlight = null;
resetProgress();
if (statusMessageText) {
setStatus(statusMessageText, statusColor);
} else {
setStatus();
}
drawBoard();
}
function updateQueenList() {
queenList.innerHTML = "";
let count = 1;
for (let row = 0; row < boardSize; row++) {
if (board[row] === EMPTY_CELL) {
continue;
}
const col = board[row];
const position = `${getColumnLabel(col)}${boardSize - row}`;
const li = document.createElement("li");
li.textContent = `${getPieceLabel(mode)} ${count}: ${position}`;
queenList.appendChild(li);
count += 1;
}
const placedPieces = count - 1;
pieceCountBadge.textContent = t("pieceCount", { count: placedPieces });
if (placedPieces === 0) {
const emptyItem = document.createElement("li");
emptyItem.className = "empty";
emptyItem.textContent = t("pieceEmpty", { modeLabel: getModeLabel(mode) });
queenList.appendChild(emptyItem);
}
}
async function saveGame() { async function saveGame() {
const response = await fetch("/save", { const response = await fetch("/save", {
@@ -1623,6 +1633,9 @@ async function deleteSavePoint(savePointId) {
setStatus(t("deleteSuccess"), "#2563eb"); setStatus(t("deleteSuccess"), "#2563eb");
} }
// -----------------------------------------------------------------------------
// Persistence and startup
function restoreSessionFromStorage() { function restoreSessionFromStorage() {
try { try {
const rawSession = window.localStorage.getItem(GAME_STORAGE_KEY); const rawSession = window.localStorage.getItem(GAME_STORAGE_KEY);
@@ -1657,11 +1670,12 @@ function restoreSessionFromStorage() {
} }
} }
// Boot order: translate UI, restore any local session, then load history data.
async function initializeDashboard() { async function initializeDashboard() {
pendingBoardSize = boardSize; pendingBoardSize = boardSize;
updateModeButtons(); updateModeButtons();
updateBoardSizeDisplays(boardSize); updateBoardSizeDisplays(boardSize);
setLanguage(getStoredLanguage() || detectBrowserLanguage()); languageController.init();
updateSettingsTrigger(false); updateSettingsTrigger(false);
setSolverMessage(t("solverReady", { modeLabel: getModeLabel(mode) })); setSolverMessage(t("solverReady", { modeLabel: getModeLabel(mode) }));
@@ -1674,18 +1688,6 @@ async function initializeDashboard() {
await refreshTimeHistory(); await refreshTimeHistory();
} }
if (langSwitch) {
langSwitch.addEventListener("click", async () => {
setLanguage(currentLanguage === "de" ? "en" : "de");
resetSaveForm();
await refreshTimeHistory();
if (savePointsSidebar.classList.contains("open")) {
await refreshSavePoints();
}
});
}
window.addEventListener("keydown", (event) => { window.addEventListener("keydown", (event) => {
if (event.key === "Escape") { if (event.key === "Escape") {
closeAllPanels(); closeAllPanels();
+43 -83
View File
@@ -1,8 +1,12 @@
// settings for preview board
const HERO_BOARD_SIZE = 8; const HERO_BOARD_SIZE = 8;
const heroBoard = document.getElementById("heroBoard"); const heroBoard = document.getElementById("heroBoard");
//translation for English and German
const AppLanguage = window.AppLanguage;
const langSwitch = document.getElementById("langSwitch"); const langSwitch = document.getElementById("langSwitch");
const supportedLanguages = ["de", "en"];
const initialLanguage = window.LANDING_INITIAL_LANGUAGE || "de"; const initialLanguage = window.LANDING_INITIAL_LANGUAGE || "de";
let currentLanguage = initialLanguage;
const translations = { const translations = {
de: { de: {
@@ -119,6 +123,43 @@ const translations = {
} }
}; };
function t(key) {
return translations[currentLanguage]?.[key] ?? null;
}
function applyTranslations(language) {
currentLanguage = language;
AppLanguage.applyTranslations({
translate: (key) => t(key),
allowHtml: (element, translatedText) => element.tagName === "TITLE" || String(translatedText).includes("<br"),
attributeMappings: [
{
selector: "[data-i18n-aria-label]",
datasetKey: "i18nAriaLabel",
attribute: "aria-label"
}
]
});
}
const languageController = AppLanguage.createController({
button: langSwitch,
defaultLanguage: initialLanguage,
onApply(language, helpers) {
applyTranslations(language);
helpers.updateLanguageSwitch({
ariaLabel: language === "de" ? "Switch language to English" : "Sprache auf Deutsch wechseln",
title: language === "de" ? "Switch to English" : "Zu Deutsch wechseln"
});
}
});
languageController.init();
// ----------------------------------------------------------------------------------------
// simple pattern for preview board
const queenPatterns = [ const queenPatterns = [
[0, 4, 7, 5, 2, 6, 1, 3], [0, 4, 7, 5, 2, 6, 1, 3],
[1, 3, 5, 7, 2, 0, 6, 4], [1, 3, 5, 7, 2, 0, 6, 4],
@@ -127,80 +168,6 @@ const queenPatterns = [
let currentPatternIndex = 0; let currentPatternIndex = 0;
function normalizeLanguage(language) {
const shortLanguage = String(language || "").trim().slice(0, 2).toLowerCase();
return supportedLanguages.includes(shortLanguage) ? shortLanguage : "de";
}
function getStoredLanguage() {
try {
return window.localStorage.getItem("preferredLanguage");
} catch (error) {
return null;
}
}
function storeLanguage(language) {
try {
window.localStorage.setItem("preferredLanguage", language);
} catch (error) {
return;
}
}
function detectBrowserLanguage() {
const browserLanguage = navigator.language || navigator.userLanguage || initialLanguage;
return normalizeLanguage(browserLanguage);
}
function setLanguage(language) {
const resolvedLanguage = normalizeLanguage(language);
const dictionary = translations[resolvedLanguage];
const i18nElements = document.querySelectorAll("[data-i18n]");
document.documentElement.lang = resolvedLanguage;
document.body.dataset.language = resolvedLanguage;
i18nElements.forEach((element) => {
const key = element.dataset.i18n;
const translatedText = dictionary[key];
if (!translatedText) {
return;
}
if (element.tagName === "TITLE" || translatedText.includes("<br")) {
element.innerHTML = translatedText;
return;
}
element.textContent = translatedText;
});
document.querySelectorAll("[data-i18n-aria-label]").forEach((element) => {
const key = element.dataset.i18nAriaLabel;
const translatedText = dictionary[key];
if (!translatedText) {
return;
}
element.setAttribute("aria-label", translatedText);
});
if (langSwitch) {
langSwitch.textContent = resolvedLanguage.toUpperCase();
langSwitch.setAttribute("aria-label", resolvedLanguage === "de" ? "Switch language to English" : "Sprache auf Deutsch wechseln");
langSwitch.title = resolvedLanguage === "de" ? "Switch to English" : "Zu Deutsch wechseln";
}
storeLanguage(resolvedLanguage);
}
function getPreferredLanguage() {
return normalizeLanguage(getStoredLanguage() || detectBrowserLanguage() || initialLanguage);
}
function createHeroBoard() { function createHeroBoard() {
if (!heroBoard) { if (!heroBoard) {
return; return;
@@ -241,16 +208,9 @@ function rotatePattern() {
applyPattern(queenPatterns[currentPatternIndex]); applyPattern(queenPatterns[currentPatternIndex]);
} }
if (langSwitch) {
langSwitch.addEventListener("click", () => {
const nextLanguage = document.documentElement.lang === "de" ? "en" : "de";
setLanguage(nextLanguage);
});
}
createHeroBoard(); createHeroBoard();
applyPattern(queenPatterns[currentPatternIndex]); applyPattern(queenPatterns[currentPatternIndex]);
setLanguage(getPreferredLanguage()); languageController.init();
if (heroBoard) { if (heroBoard) {
window.setInterval(rotatePattern, 2200); window.setInterval(rotatePattern, 2200);
+261
View File
@@ -0,0 +1,261 @@
// handels the language translation
(function attachAppLanguage(window, document) {
const DEFAULT_LANGUAGE = "de";
const DEFAULT_STORAGE_KEY = "preferredLanguage";
const DEFAULT_SUPPORTED_LANGUAGES = ["de", "en"];
function resolveSupportedLanguages(supportedLanguages = DEFAULT_SUPPORTED_LANGUAGES) {
const normalizedLanguages = Array.isArray(supportedLanguages)
? supportedLanguages
.map((language) => String(language || "").trim().slice(0, 2).toLowerCase())
.filter(Boolean)
: [];
return normalizedLanguages.length > 0 ? normalizedLanguages : DEFAULT_SUPPORTED_LANGUAGES.slice();
}
function normalizeLanguage(
language,
{
supportedLanguages = DEFAULT_SUPPORTED_LANGUAGES,
defaultLanguage = DEFAULT_LANGUAGE
} = {}
) {
const normalizedSupportedLanguages = resolveSupportedLanguages(supportedLanguages);
const normalizedDefaultLanguage = normalizedSupportedLanguages.includes(defaultLanguage)
? defaultLanguage
: normalizedSupportedLanguages[0] || DEFAULT_LANGUAGE;
const shortLanguage = String(language || "").trim().slice(0, 2).toLowerCase();
return normalizedSupportedLanguages.includes(shortLanguage) ? shortLanguage : normalizedDefaultLanguage;
}
function getStoredLanguage({ storageKey = DEFAULT_STORAGE_KEY } = {}) {
try {
return window.localStorage.getItem(storageKey);
} catch (error) {
return null;
}
}
function storeLanguage(language, { storageKey = DEFAULT_STORAGE_KEY } = {}) {
try {
window.localStorage.setItem(storageKey, language);
} catch (error) {
return;
}
}
function detectBrowserLanguage(options = {}) {
return normalizeLanguage(navigator.language || navigator.userLanguage || options.defaultLanguage, options);
}
function getPreferredLanguage(
{
supportedLanguages = DEFAULT_SUPPORTED_LANGUAGES,
defaultLanguage = DEFAULT_LANGUAGE,
storageKey = DEFAULT_STORAGE_KEY,
fallbackLanguage = defaultLanguage
} = {}
) {
return normalizeLanguage(
getStoredLanguage({ storageKey }) || detectBrowserLanguage({ supportedLanguages, defaultLanguage }) || fallbackLanguage,
{
supportedLanguages,
defaultLanguage: fallbackLanguage
}
);
}
function getNextLanguage(language, supportedLanguages = DEFAULT_SUPPORTED_LANGUAGES) {
const normalizedSupportedLanguages = resolveSupportedLanguages(supportedLanguages);
const currentLanguage = normalizeLanguage(language, {
supportedLanguages: normalizedSupportedLanguages,
defaultLanguage: normalizedSupportedLanguages[0]
});
const currentIndex = normalizedSupportedLanguages.indexOf(currentLanguage);
if (currentIndex === -1 || normalizedSupportedLanguages.length < 2) {
return currentLanguage;
}
return normalizedSupportedLanguages[(currentIndex + 1) % normalizedSupportedLanguages.length];
}
function setDocumentLanguage(language) {
if (document.documentElement) {
document.documentElement.lang = language;
}
if (document.body) {
document.body.dataset.language = language;
}
}
function applyTextTranslations({
translate,
selector = "[data-i18n]",
allowHtml = () => false
} = {}) {
if (typeof translate !== "function") {
return;
}
document.querySelectorAll(selector).forEach((element) => {
const key = element.dataset.i18n;
const translatedText = translate(key, element);
if (translatedText === undefined || translatedText === null) {
return;
}
if (allowHtml(element, translatedText, key)) {
element.innerHTML = String(translatedText);
return;
}
element.textContent = String(translatedText);
});
}
function applyAttributeTranslations({
translate,
mappings = []
} = {}) {
if (typeof translate !== "function") {
return;
}
mappings.forEach(({ selector, datasetKey, attribute }) => {
document.querySelectorAll(selector).forEach((element) => {
const key = element.dataset[datasetKey];
const translatedText = translate(key, element, attribute);
if (translatedText === undefined || translatedText === null) {
return;
}
element.setAttribute(attribute, String(translatedText));
});
});
}
function applyTranslations({
translate,
selector = "[data-i18n]",
allowHtml,
attributeMappings = []
} = {}) {
applyTextTranslations({ translate, selector, allowHtml });
applyAttributeTranslations({ translate, mappings: attributeMappings });
}
function updateLanguageSwitch(
button,
{
language,
label = String(language || "").toUpperCase(),
ariaLabel,
title
} = {}
) {
if (!button) {
return;
}
button.textContent = label;
if (ariaLabel !== undefined) {
button.setAttribute("aria-label", ariaLabel);
}
if (title !== undefined) {
button.title = title;
}
}
function createController({
button = document.getElementById("langSwitch"),
defaultLanguage = DEFAULT_LANGUAGE,
supportedLanguages = DEFAULT_SUPPORTED_LANGUAGES,
storageKey = DEFAULT_STORAGE_KEY,
onApply = () => { }
} = {}) {
const options = {
defaultLanguage,
supportedLanguages,
storageKey
};
let currentLanguage = normalizeLanguage(defaultLanguage, options);
function apply(language, source = "apply") {
currentLanguage = normalizeLanguage(language, options);
setDocumentLanguage(currentLanguage);
onApply(currentLanguage, {
source,
button,
applyTranslations,
updateLanguageSwitch: (switchOptions = {}) => updateLanguageSwitch(button, {
language: currentLanguage,
...switchOptions
}),
getNextLanguage: () => getNextLanguage(currentLanguage, supportedLanguages),
normalizeLanguage: (value) => normalizeLanguage(value, options)
});
storeLanguage(currentLanguage, { storageKey });
return currentLanguage;
}
function toggle() {
return apply(getNextLanguage(currentLanguage, supportedLanguages), "toggle");
}
function init(initialLanguage = getPreferredLanguage({
supportedLanguages,
defaultLanguage,
storageKey,
fallbackLanguage: defaultLanguage
})) {
if (button) {
button.addEventListener("click", toggle);
}
return apply(initialLanguage, "init");
}
return {
apply,
getCurrentLanguage: () => currentLanguage,
getPreferredLanguage: (fallbackLanguage = defaultLanguage) => getPreferredLanguage({
supportedLanguages,
defaultLanguage,
storageKey,
fallbackLanguage
}),
init,
toggle
};
}
window.AppLanguage = {
DEFAULT_LANGUAGE,
DEFAULT_STORAGE_KEY,
DEFAULT_SUPPORTED_LANGUAGES: DEFAULT_SUPPORTED_LANGUAGES.slice(),
applyAttributeTranslations,
applyTextTranslations,
applyTranslations,
createController,
detectBrowserLanguage,
getNextLanguage,
getPreferredLanguage,
getStoredLanguage,
normalizeLanguage,
setDocumentLanguage,
storeLanguage,
updateLanguageSwitch
};
})(window, document);
+36 -104
View File
@@ -1,9 +1,9 @@
const SUPPORTED_LANGUAGES = ["de", "en"]; //translation for English and German
const DEFAULT_LANGUAGE = "de";
const LANGUAGE_STORAGE_KEY = "preferredLanguage";
const AppLanguage = window.AppLanguage;
const langSwitch = document.getElementById("langSwitch"); const langSwitch = document.getElementById("langSwitch");
const legalPage = document.body.dataset.legalPage; const legalPage = document.body.dataset.legalPage;
let currentLanguage = AppLanguage.DEFAULT_LANGUAGE;
const translations = { const translations = {
de: { de: {
@@ -27,10 +27,10 @@ const translations = {
contactEmailLabel: "E-Mail:", contactEmailLabel: "E-Mail:",
projectResponsibilityTitle: "Projektverantwortung", projectResponsibilityTitle: "Projektverantwortung",
projectResponsibilityText: "Dieses Webprojekt wurde im Rahmen eines Schulprojekts zum Acht-Damen- und Acht-Türme-Problem erstellt. Inhaltlich verantwortlich ist die jeweils oben genannte verantwortliche Stelle.", projectResponsibilityText: "Dieses Webprojekt wurde im Rahmen eines Schulprojekts zum Acht-Damen- und Acht-Türme-Problem erstellt. Inhaltlich verantwortlich ist die jeweils oben genannte verantwortliche Stelle.",
contentLiabilityTitle: "Haftung fuer Inhalte", contentLiabilityTitle: "Haftung für Inhalte",
contentLiabilityText: "Die Inhalte dieser Website wurden mit Sorgfalt erstellt. Für die Richtigkeit, Vollstaendigkeit und Aktualitaet der Inhalte kann jedoch keine Gewaehr übernommen werden. Bei Bekanntwerden von Rechtsverletzungen werden entsprechende Inhalte umgehend überarbeitet oder entfernt.", contentLiabilityText: "Die Inhalte dieser Website wurden mit Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte kann jedoch keine Gewähr übernommen werden. Bei Bekanntwerden von Rechtsverletzungen werden entsprechende Inhalte umgehend überarbeitet oder entfernt.",
linksLiabilityTitle: "Haftung fuer Links", linksLiabilityTitle: "Haftung für Links",
linksLiabilityText: "Diese Website kann Links zu externen Angeboten enthalten. Fuer deren Inhalte sind ausschliesslich die jeweiligen Betreiber verantwortlich. Eine permanente inhaltliche Kontrolle verlinkter Seiten ist ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar.", linksLiabilityText: "Diese Website kann Links zu externen Angeboten enthalten. Für deren Inhalte sind ausschliesslich die jeweiligen Betreiber verantwortlich. Eine permanente inhaltliche Kontrolle verlinkter Seiten ist ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar.",
versionTitle: "Stand", versionTitle: "Stand",
versionText: "Stand dieser Vorlage: April 2026" versionText: "Stand dieser Vorlage: April 2026"
}, },
@@ -52,7 +52,7 @@ const translations = {
processingPurposeItem4: "Speichern und Laden von Spielständen", processingPurposeItem4: "Speichern und Laden von Spielständen",
processingPurposeItem5: "technischer Betrieb, Fehleranalyse und Missbrauchsschutz", processingPurposeItem5: "technischer Betrieb, Fehleranalyse und Missbrauchsschutz",
legalBasisTitle: "4. Rechtsgrundlage", legalBasisTitle: "4. Rechtsgrundlage",
legalBasisText: "Soweit personenbezogene Daten verarbeitet werden, erfolgt dies in der Regel zur Bereitstellung der Anwendung und ihrer Funktionen sowie aufgrund des berechtigten Interesses an einem sicheren und funktionsfaehigen Webangebot. Falls eine ausdrückliche Einwilligung erforderlich ist, wird diese gesondert eingeholt.", legalBasisText: "Soweit personenbezogene Daten verarbeitet werden, erfolgt dies in der Regel zur Bereitstellung der Anwendung und ihrer Funktionen sowie aufgrund des berechtigten Interesses an einem sicheren und funktionsfähigen Webangebot. Falls eine ausdrückliche Einwilligung erforderlich ist, wird diese gesondert eingeholt.",
storageTitle: "5. Speicherung und Löschung", storageTitle: "5. Speicherung und Löschung",
storageText: "Personenbezogene Daten werden nur so lange gespeichert, wie sie für den jeweiligen Zweck benötigt werden. Benutzerkonten und zugehörige Spielstände bleiben grundsätzlich gespeichert, bis sie gelöscht oder für das Projekt nicht mehr benötigt werden.", storageText: "Personenbezogene Daten werden nur so lange gespeichert, wie sie für den jeweiligen Zweck benötigt werden. Benutzerkonten und zugehörige Spielstände bleiben grundsätzlich gespeichert, bis sie gelöscht oder für das Projekt nicht mehr benötigt werden.",
thirdPartyTitle: "6. Weitergabe an Dritte", thirdPartyTitle: "6. Weitergabe an Dritte",
@@ -61,11 +61,11 @@ const translations = {
rightsText: "Betroffene Personen haben im Rahmen des anwendbaren Datenschutzrechts insbesondere das Recht auf:", rightsText: "Betroffene Personen haben im Rahmen des anwendbaren Datenschutzrechts insbesondere das Recht auf:",
rightsItem1: "Auskunft über die verarbeiteten personenbezogenen Daten", rightsItem1: "Auskunft über die verarbeiteten personenbezogenen Daten",
rightsItem2: "Berichtigung unrichtiger Daten", rightsItem2: "Berichtigung unrichtiger Daten",
rightsItem3: "Löschung oder Einschraenkung der Verarbeitung", rightsItem3: "Löschung oder Einschränkung der Verarbeitung",
rightsItem4: "Widerspruch gegen die Verarbeitung", rightsItem4: "Widerspruch gegen die Verarbeitung",
rightsItem5: "Herausgabe der bereitgestellten Daten, soweit anwendbar", rightsItem5: "Herausgabe der bereitgestellten Daten, soweit anwendbar",
securityTitle: "8. Datensicherheit", securityTitle: "8. Datensicherheit",
securityText: "Es werden technische und organisatorische Massnahmen getroffen, um gespeicherte Daten vor Verlust, Missbrauch oder unberechtigtem Zugriff zu schuetzen. Dazu gehoeren insbesondere passwortgeschuetzte Konten und die Möglichkeit, Zwei-Faktor-Authentifizierung zu aktivieren.", securityText: "Es werden technische und organisatorische Massnahmen getroffen, um gespeicherte Daten vor Verlust, Missbrauch oder unberechtigtem Zugriff zu schützen. Dazu gehoeren insbesondere passwortgeschützte Konten und die Möglichkeit, Zwei-Faktor-Authentifizierung zu aktivieren.",
versionTitle: "9. Stand", versionTitle: "9. Stand",
versionText: "Stand dieser Vorlage: April 2026" versionText: "Stand dieser Vorlage: April 2026"
} }
@@ -136,107 +136,39 @@ const translations = {
} }
}; };
function normalizeLanguage(language) { function t(key) {
const shortLanguage = String(language || "").trim().slice(0, 2).toLowerCase(); const commonTranslation = translations[currentLanguage]?.common?.[key];
return SUPPORTED_LANGUAGES.includes(shortLanguage) ? shortLanguage : DEFAULT_LANGUAGE;
}
function getStoredLanguage() {
try {
return window.localStorage.getItem(LANGUAGE_STORAGE_KEY);
} catch (error) {
return null;
}
}
function storeLanguage(language) {
try {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
} catch (error) {
return;
}
}
function detectBrowserLanguage() {
return normalizeLanguage(navigator.language || navigator.userLanguage || DEFAULT_LANGUAGE);
}
function t(language, key) {
const commonTranslation = translations[language]?.common?.[key];
if (commonTranslation) { if (commonTranslation) {
return commonTranslation; return commonTranslation;
} }
return translations[language]?.[legalPage]?.[key] ?? null; return translations[currentLanguage]?.[legalPage]?.[key] ?? null;
} }
function translateFooter(language) { function applyTranslations() {
const footerText = document.querySelector(".page-footer-text"); AppLanguage.applyTranslations({
const footerNav = document.querySelector(".page-footer-links"); translate: (key) => t(key),
const privacyLink = document.querySelector('.page-footer-links a[href$="/privacy-policy"]'); attributeMappings: [
const imprintLink = document.querySelector('.page-footer-links a[href$="/impressum"]'); {
selector: "[data-i18n-aria-label]",
if (footerText) { datasetKey: "i18nAriaLabel",
footerText.textContent = t(language, "footerText") || footerText.textContent; attribute: "aria-label"
} }
]
if (footerNav) {
footerNav.setAttribute("aria-label", t(language, "footerNavAria") || footerNav.getAttribute("aria-label") || "");
}
if (privacyLink) {
privacyLink.textContent = t(language, "footerPrivacy") || privacyLink.textContent;
}
if (imprintLink) {
imprintLink.textContent = t(language, "footerImprint") || imprintLink.textContent;
}
}
function applyTranslations(language) {
document.documentElement.lang = language;
document.body.dataset.language = language;
document.querySelectorAll("[data-i18n]").forEach((element) => {
const translatedText = t(language, element.dataset.i18n);
if (!translatedText) {
return;
}
element.textContent = translatedText;
});
document.querySelectorAll("[data-i18n-aria-label]").forEach((element) => {
const translatedText = t(language, element.dataset.i18nAriaLabel);
if (!translatedText) {
return;
}
element.setAttribute("aria-label", translatedText);
});
translateFooter(language);
if (langSwitch) {
langSwitch.textContent = language.toUpperCase();
langSwitch.setAttribute("aria-label", t(language, "languageSwitchAria") || "Sprache wechseln");
langSwitch.title = language === "de" ? "Switch to English" : "Zu Deutsch wechseln";
}
storeLanguage(language);
}
function setLanguage(language) {
applyTranslations(normalizeLanguage(language));
}
setLanguage(getStoredLanguage() || detectBrowserLanguage());
if (langSwitch) {
langSwitch.addEventListener("click", () => {
setLanguage(document.documentElement.lang === "de" ? "en" : "de");
}); });
} }
const languageController = AppLanguage.createController({
button: langSwitch,
onApply(language, helpers) {
currentLanguage = language;
applyTranslations();
helpers.updateLanguageSwitch({
ariaLabel: t("languageSwitchAria") || "Sprache wechseln",
title: language === "de" ? "Switch to English" : "Zu Deutsch wechseln"
});
}
});
languageController.init();
+31 -64
View File
@@ -1,7 +1,6 @@
const SUPPORTED_LANGUAGES = ["de", "en"]; //translation for English and German
const DEFAULT_LANGUAGE = "de";
const LANGUAGE_STORAGE_KEY = "preferredLanguage";
const AppLanguage = window.AppLanguage;
const langSwitch = document.getElementById("langSwitch"); const langSwitch = document.getElementById("langSwitch");
const translations = { const translations = {
@@ -112,32 +111,7 @@ const serverMessageTranslations = {
en: {} en: {}
}; };
let currentLanguage = DEFAULT_LANGUAGE; let currentLanguage = AppLanguage.DEFAULT_LANGUAGE;
function normalizeLanguage(language) {
const shortLanguage = String(language || "").trim().slice(0, 2).toLowerCase();
return SUPPORTED_LANGUAGES.includes(shortLanguage) ? shortLanguage : DEFAULT_LANGUAGE;
}
function getStoredLanguage() {
try {
return window.localStorage.getItem(LANGUAGE_STORAGE_KEY);
} catch (error) {
return null;
}
}
function storeLanguage(language) {
try {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
} catch (error) {
return;
}
}
function detectBrowserLanguage() {
return normalizeLanguage(navigator.language || navigator.userLanguage || DEFAULT_LANGUAGE);
}
function t(key) { function t(key) {
return translations[currentLanguage]?.[key] ?? translations.en[key] ?? key; return translations[currentLanguage]?.[key] ?? translations.en[key] ?? key;
@@ -172,29 +146,25 @@ function translateServerMessage(message) {
} }
function applyTextTranslations() { function applyTextTranslations() {
document.documentElement.lang = currentLanguage; AppLanguage.applyTranslations({
translate: (key) => t(key),
document.querySelectorAll("[data-i18n]").forEach((element) => { attributeMappings: [
const translatedText = t(element.dataset.i18n); {
selector: "[data-i18n-aria-label]",
if (element.tagName === "TITLE") { datasetKey: "i18nAriaLabel",
element.textContent = translatedText; attribute: "aria-label"
return; },
{
selector: "[data-i18n-loading-label]",
datasetKey: "i18nLoadingLabel",
attribute: "data-loading-label"
} }
]
element.textContent = translatedText;
});
document.querySelectorAll("[data-i18n-aria-label]").forEach((element) => {
element.setAttribute("aria-label", t(element.dataset.i18nAriaLabel));
}); });
document.querySelectorAll("[data-i18n-loading-label]").forEach((element) => { document.querySelectorAll("[data-i18n-loading-label]").forEach((element) => {
const translatedLoadingLabel = t(element.dataset.i18nLoadingLabel);
element.dataset.loadingLabel = translatedLoadingLabel;
if (element.dataset.loadingLabelApplied === "true") { if (element.dataset.loadingLabelApplied === "true") {
element.textContent = translatedLoadingLabel; element.textContent = element.dataset.loadingLabel;
} }
}); });
@@ -205,30 +175,27 @@ function applyTextTranslations() {
element.textContent = translateServerMessage(element.dataset.originalText); element.textContent = translateServerMessage(element.dataset.originalText);
}); });
if (langSwitch) {
langSwitch.textContent = currentLanguage.toUpperCase();
langSwitch.setAttribute("aria-label", t("languageSwitchAria"));
langSwitch.title = currentLanguage === "de" ? "Switch to English" : "Zu Deutsch wechseln";
}
} }
function setLanguage(language) { const languageController = AppLanguage.createController({
currentLanguage = normalizeLanguage(language); button: langSwitch,
onApply(language, helpers) {
currentLanguage = language;
applyTextTranslations(); applyTextTranslations();
storeLanguage(currentLanguage); helpers.updateLanguageSwitch({
} ariaLabel: t("languageSwitchAria"),
title: currentLanguage === "de" ? "Switch to English" : "Zu Deutsch wechseln"
setLanguage(getStoredLanguage() || detectBrowserLanguage());
if (langSwitch) {
langSwitch.addEventListener("click", () => {
setLanguage(currentLanguage === "de" ? "en" : "de");
}); });
} }
});
languageController.init();
// -----------------------------------------------------------------------------------------------
const magneticButtons = document.querySelectorAll(".magnetic"); const magneticButtons = document.querySelectorAll(".magnetic");
// magnetic button effect for desktop user
magneticButtons.forEach((button) => { magneticButtons.forEach((button) => {
button.addEventListener("mousemove", (event) => { button.addEventListener("mousemove", (event) => {
const rect = button.getBoundingClientRect(); const rect = button.getBoundingClientRect();
+2 -1
View File
@@ -364,7 +364,8 @@
</div> </div>
</section> </section>
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script> <script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/dashboard.js') }}" defer></script>
</body> </body>
</html> </html>
+1
View File
@@ -76,6 +76,7 @@
</main> </main>
{% include "partials/footer.html" %} {% include "partials/footer.html" %}
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/legal.js') }}" defer></script> <script src="{{ url_for('static', filename='js/legal.js') }}" defer></script>
</body> </body>
+2 -1
View File
@@ -190,7 +190,8 @@
<script> <script>
window.LANDING_INITIAL_LANGUAGE = "{{ initial_language }}"; window.LANDING_INITIAL_LANGUAGE = "{{ initial_language }}";
</script> </script>
<script src="{{ url_for('static', filename='js/index.js') }}"></script> <script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/index.js') }}" defer></script>
</body> </body>
</html> </html>
+4 -2
View File
@@ -13,7 +13,7 @@
<nav class="auth-nav"> <nav class="auth-nav">
<a class="brand" href="{{ url_for('main.home') }}"> <a class="brand" href="{{ url_for('main.home') }}">
<span class="brand-mark">♛</span> <span class="brand-mark">♛</span>
<span>8-Damen- und 8-Tuerme Solver</span> <span class="nav-brand">8-Damen- und 8-Türme Solver</span>
</a> </a>
<ul class="nav-links"> <ul class="nav-links">
@@ -25,7 +25,8 @@
<main class="auth-shell"> <main class="auth-shell">
<section class="auth-panel"> <section class="auth-panel">
<h1>Login to your account</h1> <h1>Login to your account</h1>
<p class="muted">If two-factor authentication is enabled, enter the current 6-digit code from your authenticator app after the password check.</p> <p class="muted">If two-factor authentication is enabled, enter the current 6-digit code from your
authenticator app after the password check.</p>
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
@@ -53,6 +54,7 @@
{% include "partials/footer.html" %} {% include "partials/footer.html" %}
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script> <script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
</body> </body>
+1
View File
@@ -155,6 +155,7 @@
{% include "partials/footer.html" %} {% include "partials/footer.html" %}
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script> <script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
</body> </body>
+1
View File
@@ -126,6 +126,7 @@
</main> </main>
{% include "partials/footer.html" %} {% include "partials/footer.html" %}
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/legal.js') }}" defer></script> <script src="{{ url_for('static', filename='js/legal.js') }}" defer></script>
</body> </body>
+1
View File
@@ -54,6 +54,7 @@
{% include "partials/footer.html" %} {% include "partials/footer.html" %}
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script> <script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
</body> </body>
+1
View File
@@ -51,6 +51,7 @@
{% include "partials/footer.html" %} {% include "partials/footer.html" %}
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script> <script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
</body> </body>