Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
const KIOSK_SKELETON_MIN_MS = 300;
|
||||
const KIOSK_SKELETON_EXIT_MS = 160;
|
||||
|
||||
const GRID_CARD_INNER = `
|
||||
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--kiosk-title"></div>
|
||||
<div class="kiosk-skeleton-info">
|
||||
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--kiosk-meta"></div>
|
||||
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--kiosk-meta"></div>
|
||||
</div>`;
|
||||
|
||||
const GROUP_ITEM_INNER = `
|
||||
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--title"></div>
|
||||
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta"></div>`;
|
||||
|
||||
export function buildGridSkeletonCards(rowCount = 4) {
|
||||
return Array.from({ length: rowCount }, () =>
|
||||
`<div class="dispenser-card kiosk-skeleton-card" aria-hidden="true">${GRID_CARD_INNER}</div>`
|
||||
).join("");
|
||||
}
|
||||
|
||||
export function buildDispenserGridSkeletonHtml(rowCount = 4) {
|
||||
return buildGridSkeletonCards(rowCount);
|
||||
}
|
||||
|
||||
export function buildPeriodRecipeGridSkeletonHtml(rowCount = 4) {
|
||||
return buildGridSkeletonCards(rowCount);
|
||||
}
|
||||
|
||||
export function buildUnloadingGroupsSkeletonHtml(rowCount = 3) {
|
||||
const items = Array.from({ length: rowCount }, () =>
|
||||
`<div class="kiosk-skeleton-group-item">${GROUP_ITEM_INNER}</div>`
|
||||
).join("");
|
||||
return `<div class="kiosk-skeleton-groups" data-skeleton-variant="groups" aria-busy="true" aria-label="Загрузка групп выгрузки">${items}</div>`;
|
||||
}
|
||||
|
||||
export function buildDuplicatePanelSkeletonHtml() {
|
||||
return `<div class="kiosk-skeleton-panel" data-skeleton-variant="duplicate" aria-busy="true" aria-label="Загрузка данных">
|
||||
<div class="kiosk-skeleton-recipe-header">
|
||||
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--title"></div>
|
||||
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--subtitle"></div>
|
||||
</div>
|
||||
<div class="dashboard-skeleton-bar kiosk-skeleton-component-name"></div>
|
||||
<div class="dashboard-skeleton-bar kiosk-skeleton-weight-hero"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function buildRecipeIngredientsSkeletonHtml(rowCount = 6) {
|
||||
const rows = Array.from({ length: rowCount }, () =>
|
||||
`<div class="kiosk-skeleton-table-row">
|
||||
<div class="dashboard-skeleton-bar"></div>
|
||||
<div class="dashboard-skeleton-bar"></div>
|
||||
</div>`
|
||||
).join("");
|
||||
return `<div class="kiosk-skeleton-table" data-skeleton-variant="ingredients" aria-busy="true" aria-label="Загрузка ингредиентов">${rows}</div>`;
|
||||
}
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
|
||||
}
|
||||
|
||||
function isGridSkeletonHost(container) {
|
||||
if (!container) return false;
|
||||
return (
|
||||
container.getAttribute("aria-busy") === "true" &&
|
||||
container.querySelector(":scope > .kiosk-skeleton-card") != null
|
||||
);
|
||||
}
|
||||
|
||||
function findSkeletonRoot(container) {
|
||||
if (!container) return null;
|
||||
if (isGridSkeletonHost(container)) return container;
|
||||
return container.querySelector(
|
||||
":scope > .kiosk-skeleton-groups, :scope > .kiosk-skeleton-table, :scope > .kiosk-skeleton-panel"
|
||||
);
|
||||
}
|
||||
|
||||
export function mountKioskSkeleton(container, html, label) {
|
||||
if (!container) return performance.now();
|
||||
container.innerHTML = html;
|
||||
container.classList.remove("kiosk-skeleton-host--exit");
|
||||
if (container.querySelector(":scope > .kiosk-skeleton-card")) {
|
||||
container.setAttribute("aria-busy", "true");
|
||||
if (label) container.setAttribute("aria-label", label);
|
||||
}
|
||||
return performance.now();
|
||||
}
|
||||
|
||||
export async function awaitKioskSkeletonMin(startedAt, minMs = KIOSK_SKELETON_MIN_MS) {
|
||||
const elapsed = performance.now() - startedAt;
|
||||
const rest = minMs - elapsed;
|
||||
if (rest > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, rest));
|
||||
}
|
||||
}
|
||||
|
||||
async function fadeOutSkeleton(skel) {
|
||||
if (!skel || prefersReducedMotion()) return;
|
||||
const exitClass = isGridSkeletonHost(skel)
|
||||
? "kiosk-skeleton-host--exit"
|
||||
: skel.classList.contains("kiosk-skeleton-groups")
|
||||
? "kiosk-skeleton-groups--exit"
|
||||
: skel.classList.contains("kiosk-skeleton-table")
|
||||
? "kiosk-skeleton-table--exit"
|
||||
: "kiosk-skeleton-panel--exit";
|
||||
skel.classList.add(exitClass);
|
||||
await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
skel.removeEventListener("transitionend", onEnd);
|
||||
resolve();
|
||||
};
|
||||
const onEnd = (event) => {
|
||||
if (event.target === skel && event.propertyName === "opacity") finish();
|
||||
};
|
||||
skel.addEventListener("transitionend", onEnd);
|
||||
globalThis.setTimeout(finish, KIOSK_SKELETON_EXIT_MS + 40);
|
||||
});
|
||||
}
|
||||
|
||||
function clearSkeletonHostAttrs(container) {
|
||||
if (!container) return;
|
||||
container.classList.remove("kiosk-skeleton-host--exit");
|
||||
container.removeAttribute("aria-busy");
|
||||
container.removeAttribute("aria-label");
|
||||
}
|
||||
|
||||
export async function replaceKioskContent(container, html, options = {}) {
|
||||
if (!container) return;
|
||||
const { minMs = KIOSK_SKELETON_MIN_MS, startedAt = performance.now() } = options;
|
||||
const skel = findSkeletonRoot(container);
|
||||
if (skel) {
|
||||
await awaitKioskSkeletonMin(startedAt, minMs);
|
||||
await fadeOutSkeleton(skel);
|
||||
} else {
|
||||
await awaitKioskSkeletonMin(startedAt, minMs);
|
||||
}
|
||||
container.innerHTML = html;
|
||||
clearSkeletonHostAttrs(container);
|
||||
}
|
||||
|
||||
export function clearStaleKioskSkeleton(container) {
|
||||
if (!container) return;
|
||||
if (findSkeletonRoot(container)) {
|
||||
container.innerHTML = "";
|
||||
clearSkeletonHostAttrs(container);
|
||||
}
|
||||
}
|
||||
|
||||
export function mountDuplicatePanelSkeleton(recipeInfoEl, weightSectionEl) {
|
||||
const html = buildDuplicatePanelSkeletonHtml();
|
||||
const startedAt = performance.now();
|
||||
if (recipeInfoEl) {
|
||||
recipeInfoEl.style.display = "block";
|
||||
recipeInfoEl.innerHTML = html;
|
||||
}
|
||||
if (weightSectionEl) {
|
||||
weightSectionEl.classList.add("kiosk-skeleton-host");
|
||||
const host = weightSectionEl.querySelector(".weight-content");
|
||||
if (host) {
|
||||
host.dataset.kioskSkeletonBackup = host.innerHTML;
|
||||
host.innerHTML = `<div class="kiosk-skeleton-panel" aria-busy="true" aria-hidden="true">
|
||||
<div class="dashboard-skeleton-bar kiosk-skeleton-component-name"></div>
|
||||
<div class="dashboard-skeleton-bar kiosk-skeleton-weight-hero"></div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
return startedAt;
|
||||
}
|
||||
|
||||
export function clearDuplicatePanelSkeleton(recipeInfoEl, weightSectionEl) {
|
||||
if (recipeInfoEl) {
|
||||
const skel = recipeInfoEl.querySelector(".kiosk-skeleton-panel");
|
||||
if (skel) {
|
||||
recipeInfoEl.style.display = "none";
|
||||
recipeInfoEl.innerHTML = `
|
||||
<h3>Рецепт: <span id="recipeName"></span></h3>
|
||||
<p>Компонент <span id="currentIndex">0</span> из <span id="totalComponents">0</span></p>`;
|
||||
}
|
||||
}
|
||||
if (weightSectionEl) {
|
||||
weightSectionEl.classList.remove("kiosk-skeleton-host");
|
||||
const host = weightSectionEl.querySelector(".weight-content");
|
||||
if (host?.dataset.kioskSkeletonBackup) {
|
||||
host.innerHTML = host.dataset.kioskSkeletonBackup;
|
||||
delete host.dataset.kioskSkeletonBackup;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const api = {
|
||||
buildGridSkeletonCards,
|
||||
buildDispenserGridSkeletonHtml,
|
||||
buildPeriodRecipeGridSkeletonHtml,
|
||||
buildUnloadingGroupsSkeletonHtml,
|
||||
buildDuplicatePanelSkeletonHtml,
|
||||
buildRecipeIngredientsSkeletonHtml,
|
||||
mountKioskSkeleton,
|
||||
replaceKioskContent,
|
||||
awaitKioskSkeletonMin,
|
||||
clearStaleKioskSkeleton,
|
||||
mountDuplicatePanelSkeleton,
|
||||
clearDuplicatePanelSkeleton,
|
||||
};
|
||||
|
||||
if (typeof globalThis !== "undefined") {
|
||||
globalThis.WespKioskSkeleton = api;
|
||||
}
|
||||
Reference in New Issue
Block a user