Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
import { mountWespLogoLoader } from "/static/js/wesp-logo-loader.js?v=6";
|
||||
import {
|
||||
markLoginTransition,
|
||||
storePreloadedApiPayload,
|
||||
PRELOAD_FEED_DISPENSERS_KEY,
|
||||
} from "/static/js/wesp-nav-preload.js";
|
||||
|
||||
const LS_LOGIN = "wesp_saved_login";
|
||||
const LS_REMEMBER = "wesp_remember_login";
|
||||
const THEME_STORAGE_KEY = "theme";
|
||||
const POST_LOGIN_REDIRECT_DELAY_MS = 300;
|
||||
/** Длительность анимации логотипа после успешного входа (на странице login). */
|
||||
const LOGIN_EXIT_ANIM_MS = 3500;
|
||||
|
||||
const RECIPES_WARM_URLS = [
|
||||
"/recipes",
|
||||
"/static/css/bootstrap.min.css",
|
||||
"/static/css/wesp-zootech-shell.css",
|
||||
"/static/css/wesp-zootech-layout.css",
|
||||
"/static/css/wesp-zootech-components.css",
|
||||
"/static/css/wesp-recipes-skeleton.css",
|
||||
"/static/css/wesp-logo-loader.css",
|
||||
"/static/css/notyf.min.css",
|
||||
"/static/css/font-awesome.min.css",
|
||||
"/static/js/wesp-theme-boot.js",
|
||||
"/static/js/wesp-logo-loader.js?v=6",
|
||||
"/static/js/wesp-page-enter.js",
|
||||
"/static/js/wesp-nav-logo-shimmer.js",
|
||||
"/static/js/wesp-zootech-nav.js",
|
||||
"/static/js/notyf.min.js",
|
||||
"/static/js/bootstrap.bundle.min.js",
|
||||
"/static/js/wesp-dialog.js",
|
||||
"/static/js/pages/recipes-page.js",
|
||||
"/static/js/pages/recipes-auth-settings.js",
|
||||
"/static/js/pages/recipes-data-controller.js",
|
||||
"/static/js/pages/recipes-operations-controller.js",
|
||||
"/static/js/pages/recipes-editor-controller.js",
|
||||
"/static/js/modules/app-state.js",
|
||||
];
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function preloadUrl(url) {
|
||||
return fetch(url, { credentials: "same-origin" }).catch(() => null);
|
||||
}
|
||||
|
||||
async function preloadRecipesAfterLogin() {
|
||||
markLoginTransition();
|
||||
|
||||
const apiTask = preloadUrl("/api/feed_dispensers").then(async (response) => {
|
||||
if (!response?.ok) return;
|
||||
const payload = await response.json();
|
||||
if (Array.isArray(payload)) {
|
||||
storePreloadedApiPayload(PRELOAD_FEED_DISPENSERS_KEY, payload);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled([
|
||||
apiTask,
|
||||
...RECIPES_WARM_URLS.map((url) => preloadUrl(url)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function finishLoginTransition(target) {
|
||||
const preloadPromise = preloadRecipesAfterLogin();
|
||||
await playLoginExitAnimation();
|
||||
await Promise.all([preloadPromise, delay(POST_LOGIN_REDIRECT_DELAY_MS)]);
|
||||
}
|
||||
|
||||
function themeForLogoLoader() {
|
||||
try {
|
||||
const saved = (localStorage.getItem(THEME_STORAGE_KEY) || "").trim().toLowerCase();
|
||||
if (saved === "light" || saved === "organic" || saved === "dark") return saved;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const attr = document.documentElement.getAttribute("data-theme");
|
||||
if (attr === "light" || attr === "organic" || attr === "dark") return attr;
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function isDarkTheme() {
|
||||
return themeForLogoLoader() === "dark";
|
||||
}
|
||||
|
||||
function safeNextPath(raw) {
|
||||
if (raw == null || typeof raw !== "string") return null;
|
||||
const s = raw.trim();
|
||||
if (!s || s.charAt(0) !== "/") return null;
|
||||
if (s.indexOf("//") === 0) return null;
|
||||
if (s.indexOf("://") !== -1) return null;
|
||||
if (s.indexOf("\0") !== -1 || s.indexOf("\r") !== -1 || s.indexOf("\n") !== -1) return null;
|
||||
const pathOnly = s.split("?", 1)[0];
|
||||
if (pathOnly.indexOf("@") !== -1) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
function postLoginRedirectUrl() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const n = safeNextPath(params.get("next"));
|
||||
if (n) return n;
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
return "/recipes";
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const errorDiv = document.getElementById("errorMessage");
|
||||
if (!errorDiv) return;
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.display = "block";
|
||||
window.setTimeout(() => {
|
||||
errorDiv.style.display = "none";
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
const button = document.getElementById("authButton");
|
||||
const buttonText = document.getElementById("buttonText");
|
||||
const spinner = document.getElementById("loadingSpinner");
|
||||
const loginInput = document.getElementById("loginInput");
|
||||
const passwordInput = document.getElementById("passwordInput");
|
||||
const rememberInput = document.getElementById("rememberInput");
|
||||
|
||||
if (button) button.disabled = loading;
|
||||
if (loginInput) loginInput.disabled = loading;
|
||||
if (passwordInput) passwordInput.disabled = loading;
|
||||
if (rememberInput) rememberInput.disabled = loading;
|
||||
|
||||
if (buttonText && spinner) {
|
||||
buttonText.style.display = loading ? "none" : "block";
|
||||
spinner.style.display = loading ? "block" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
function applyRememberToStorage(login, remember) {
|
||||
if (remember) {
|
||||
try {
|
||||
localStorage.setItem(LS_LOGIN, login);
|
||||
localStorage.setItem(LS_REMEMBER, "1");
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
localStorage.removeItem(LS_LOGIN);
|
||||
localStorage.removeItem(LS_REMEMBER);
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadRememberFromStorage() {
|
||||
try {
|
||||
if (localStorage.getItem(LS_REMEMBER) === "1") {
|
||||
const saved = localStorage.getItem(LS_LOGIN);
|
||||
if (saved) {
|
||||
const loginInput = document.getElementById("loginInput");
|
||||
const rememberInput = document.getElementById("rememberInput");
|
||||
if (loginInput) loginInput.value = saved;
|
||||
if (rememberInput) rememberInput.checked = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSession() {
|
||||
try {
|
||||
const r = await fetch("/api/auth/check", { credentials: "same-origin" });
|
||||
const data = await r.json();
|
||||
if (data.authenticated && data.remember_login) {
|
||||
const target = postLoginRedirectUrl();
|
||||
await finishLoginTransition(target);
|
||||
window.location.href = target;
|
||||
}
|
||||
} catch (e) {
|
||||
/* stay on login */
|
||||
}
|
||||
}
|
||||
|
||||
function playLoginExitAnimation() {
|
||||
return new Promise((resolve) => {
|
||||
const page = document.querySelector(".z-login-page");
|
||||
if (page) page.classList.add("z-login-page--hidden");
|
||||
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "z-login-backdrop";
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "z-login-loader";
|
||||
const root = document.createElement("div");
|
||||
wrap.appendChild(root);
|
||||
document.body.appendChild(wrap);
|
||||
|
||||
mountWespLogoLoader(root, {
|
||||
loop: false,
|
||||
durationMs: LOGIN_EXIT_ANIM_MS,
|
||||
totalDurationMs: LOGIN_EXIT_ANIM_MS,
|
||||
embedded: true,
|
||||
transparentBackground: true,
|
||||
knockoutBackground: true,
|
||||
theme: themeForLogoLoader(),
|
||||
onComplete: () => resolve(),
|
||||
}).catch(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function authenticate(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const login = document.getElementById("loginInput")?.value?.trim() || "";
|
||||
const password = document.getElementById("passwordInput")?.value || "";
|
||||
const remember = !!document.getElementById("rememberInput")?.checked;
|
||||
|
||||
if (!login) {
|
||||
showError("Введите логин");
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
showError("Введите пароль");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ login, password, remember }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "success") {
|
||||
applyRememberToStorage(login, remember);
|
||||
const target = postLoginRedirectUrl();
|
||||
await finishLoginTransition(target);
|
||||
window.location.href = target;
|
||||
return;
|
||||
}
|
||||
|
||||
showError(data.message || "Неверный логин или пароль");
|
||||
const passwordInput = document.getElementById("passwordInput");
|
||||
if (passwordInput) {
|
||||
passwordInput.value = "";
|
||||
passwordInput.focus();
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
showError("Ошибка соединения. Попробуйте снова.");
|
||||
console.error("Authentication error:", error);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function initLoginPage() {
|
||||
loadRememberFromStorage();
|
||||
checkSession();
|
||||
document.getElementById("loginInput")?.focus();
|
||||
|
||||
document.getElementById("loginForm")?.addEventListener("submit", authenticate);
|
||||
document.getElementById("loginInput")?.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter") document.getElementById("passwordInput")?.focus();
|
||||
});
|
||||
document.getElementById("passwordInput")?.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter") authenticate(e);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initLoginPage);
|
||||
} else {
|
||||
initLoginPage();
|
||||
}
|
||||
Reference in New Issue
Block a user