UNPKG

studiocms

Version:

Astro Native CMS for AstroDB. Built from the ground up by the Astro community.

665 lines (646 loc) 23.3 kB
const PERMISSION_HIERARCHY = { owner: /* @__PURE__ */ new Set(["owner"]), admin: /* @__PURE__ */ new Set(["owner", "admin"]), editor: /* @__PURE__ */ new Set(["owner", "admin", "editor"]), visitor: /* @__PURE__ */ new Set(["owner", "admin", "editor", "visitor"]), unknown: /* @__PURE__ */ new Set(["owner", "admin", "editor", "visitor", "unknown"]) }; const KNOWN_API_ROUTES = ["/studiocms_api/", "/_studiocms-devapps/", "/_web-vitals"]; const DEFAULT_AVATAR = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAADSklEQVR4AdSWS2xMYRTHjQXRRupRKVWrJhrxSCRigTQpYUHq0VYsarwi8RgJ0rCwFJEQEQ1psZFQXWgGRZB6NdFalkSFFRutV1UJNhLj9+dcOubeud8dC2lzfvd/7vedc77TO999DB/2n/+GZgOpVCoOSegx5K/N5WJGugIsNhHusNBZqIJiQ/455tqgiDFni9QAVZthIVyBBTAWJsBKeAOL4Tw4m3MD/GebqVoBV2Ox2ArohAHog1bGZ0MfLCJ2A+pkzg1QrRpkB3X4G5p4xdhOkOknkYYSpYE5Vu2pqZ/ctUFdDXOzS5QGvlkplxyXmJ/lnAOJfgSyMh0C0AbVVJcOLkRp4JQV3GeaJmy8SQzUg6xRBxecG2CTaaffpGgli12DeVAAhaDb8CFzhXCZ2Ouokzk3YNU2ordhGXTCALyDS6AH0C10CzhbpAb4z16DHjZxVtCivWgPJCHO3BJ4i+9skRrwqrJIE1TBZCiBGmjy5qNoTg1EWSAsNlIDbLZi2AEt0A2fDPkX8BOguyFs3d/zTg1QNB+OkPUcjkMNTIfRhvzV+CfgBbGHYBR+qIU2QCG97dqpVAcjoRuOgpqYggr5GnvCuWL2ou3kjkOzWtYGKFBAdgfoPfAR1S02iw1XB0l4achXgzOJ2QaKnYt2WA1cfwtsgETNtZA2FXR/l7LYaUhx7muag5NMloI+XKahzdSKob6mRXwnGEyA7nm949dQ+D3nTmax2hN6SC0lSVcOyTTfBuhYl36/hddS8IP5zmI56y3hADW1Ye30j/g2wLQ21Ri0i0JtaE5G7g0SH8N40PsCSbegBiot7J7pv4hXw6uZViuogRkWpdvP3JzFq6E7JKNIUAP63FbwJn67KsjTSRSUA/o2XGd5Xk07/SVBDei7/zshq0Bvun6KtcIeqIUKKAM9IfNQ+RrTnGL07dBvufrtVesM5xnm2wCbZyuRer9vR+/DCFgOh0FvPX18PsP/DF9AvsY0pxjFKke5up2LqLmLuAzzbUBRJOh7vxEt57wEVKABvQgPQO+Fr6iQrzHNKWY343pNl5PfAHqWMJRpgQ0MDqVAL9RDAqphPujJmI8K+RrTnGKOMa6PlcFlfH2nBnwzHQfDwn4AAAD//6qWhy8AAAAGSURBVAMAJXQ0UKI3Vu0AAAAASUVORK5CYII="; const COMPONENT_STYLES = ` :host { --border: hsl(240 5% 17%); --background-base: hsl(0 0% 6%); --background-step-1: hsl(0 0% 8%); --background-step-2: hsl(0 0% 10%); --background-step-3: hsl(0 0% 14%); --primary-base: hsl(259 83% 73%); --success-base: hsl(142 71% 46%); --warning-base: hsl(48 96% 53%); --danger-base: hsl(339 97% 31%); --info-base: hsl(217 92% 52%); --light: 70; --threshold: 50; } [data-theme="light"] { --border: hsl(263 5% 68%); --background-base: hsl(0 0% 97%); --background-step-1: hsl(0 0% 90%); --background-step-2: hsl(0 0% 85%); --background-step-3: hsl(0 0% 80%); --primary-base: hsl(259 85% 61%); --success-base: hsl(142 59% 47%); --warning-base: hsl(48 92% 46%); --danger-base: hsl(339 97% 31%); --info-base: hsl(217 92% 52%); } .menu_overlay { position: fixed; background: rgba(0,0,0,0.4); inset: 0; z-index: 500; display: none; } .menu_overlay.menuOpened { display: block; } .cornerMenu { position: fixed; right: 25px; bottom: 25px; width: 50px; height: 50px; background: var(--background-step-1); box-shadow: 0 3px 7px rgba(0,0,0,0.3); border-radius: 50%; z-index: 600; cursor: pointer; transition: transform 0.3s ease; display: flex; align-items: center; justify-content: center; } .avatar-container { width: 100%; height: 100%; border-radius: 50%; background: var(--background-step-1); border: 1px solid var(--border); object-fit: cover; z-index: 700; transition: transform 0.3s ease; display: flex; align-items: center; justify-content: center; } .avatar { width: 100%; height: 100%; background: var(--background-step-1); border: 1px solid var(--border); border-radius: 50%; object-fit: cover; transition: transform 0.3s ease; display: flex; align-items: center; justify-content: center; } .avatar-error { width: 100%; height: auto; margin: 2.5rem; border: none; } .cornerMenu.menuOpened .avatar-container { transform: scale(1.5); border: 1px solid var(--border); } .menu { --switch: calc((var(--light) - var(--threshold)) * -100%); position: absolute; width: 32px; height: 32px; background: var(--background-step-2); box-shadow: 0 3px 7px rgba(0,0,0,0.1); border-radius: 50%; border: 1px solid var(--border); color: hsl(0, 0%, var(--switch)); display: flex; align-items: center; justify-content: center; padding: 8px; top: -5px; left: -5px; opacity: 0; z-index: 550; pointer-events: none; user-select: none; transition: all 0.4s ease-in-out; text-decoration: none; } .menu svg { width: 24px; height: 24px; } .cornerMenu.menuOpened .menu { opacity: 1; cursor: pointer; transition: transform 0.3s ease, opacity 0.3s ease, background-color 0.15s ease; } .cornerMenu.menuOpened .menu:hover { background: var(--background-step-3); } /* Click protection: Only enable pointer events when menu is ready */ .cornerMenu.menu-ready .menu { pointer-events: all; box-shadow: 0 3px 7px rgba(0,0,0,0.1), 0 0 0 1px color-mix(in hsl, var(--primary-base) 20%, transparent); } .cornerMenu.menu-ready .menu:hover { box-shadow: 0 3px 7px rgba(0,0,0,0.2), 0 0 0 2px color-mix(in hsl, var(--primary-base) 40%, transparent); } /* Visual feedback for ignored clicks */ .menu.click-ignored { animation: shake 0.3s ease-in-out; } @keyframes shake { 0%, 100% { transform: translateX(0) translateY(0); } 25% { transform: translateX(-2px) translateY(-1px); } 50% { transform: translateX(2px) translateY(1px); } 75% { transform: translateX(-1px) translateY(-2px); } } .cornerMenu.menuOpened .menu:nth-child(1) { transform: translate(-105px, 20px); transition-delay: 0s; } .cornerMenu.menuOpened .menu:nth-child(2) { transform: translate(-78px, -33px); transition-delay: 0.05s; } .cornerMenu.menuOpened .menu:nth-child(3) { transform: translate(-38px, -76px); transition-delay: 0.1s; } .cornerMenu.menuOpened .menu:nth-child(4) { transform: translate(18px, -99px); transition-delay: 0.15s; } .menu.logout { color: var(--danger-base); } .menu.profile { color: var(--primary-base); } .menu.dashboard { color: var(--success-base); } .menu.edit { color: var(--warning-base); } `; function verifyUserPermissionLevel(userLevel, requiredLevel) { return PERMISSION_HIERARCHY[requiredLevel]?.has(userLevel) ?? false; } function shouldSkipRendering(pathname) { return KNOWN_API_ROUTES.some((route) => pathname.includes(route)); } function isDashboardRoute(pathname, dashboardRoute) { return pathname.includes(dashboardRoute); } class UserQuickTools extends HTMLElement { sessionData = null; isMenuOpen = false; menuItemsReady = false; lastMenuToggleTime = 0; readyTimeout = null; themeObserver = null; cornerMenu = null; menuOverlay = null; isInitialized = false; userInteractionListeners = []; // Click protection settings CLICK_PROTECTION_DURATION = 400; // milliseconds MENU_READY_DELAY = 350; // milliseconds (after animation completes) // Static menu items configuration static MENU_ITEMS = [ { name: "Logout", svg: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 9V5.25A2.25 2.25 0 0 1 10.5 3h6a2.25 2.25 0 0 1 2.25 2.25v13.5A2.25 2.25 0 0 1 16.5 21h-6a2.25 2.25 0 0 1-2.25-2.25V15m-3 0-3-3m0 0 3-3m-3 3H15" /></svg>', permission: "visitor", cssClass: "logout" }, { name: "Profile", svg: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" /></svg>', permission: "visitor", cssClass: "profile" }, { name: "Dashboard", svg: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 0 1-1.125-1.125v-3.75ZM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-8.25ZM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-2.25Z" /></svg>', permission: "editor", cssClass: "dashboard" }, { name: "Edit", svg: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>', permission: "editor", cssClass: "edit" } ]; constructor() { super(); this.attachShadow({ mode: "open" }); } connectedCallback() { const pathname = window.location.pathname; if (shouldSkipRendering(pathname)) { return; } this.initOnUserInteraction(); } /* v8 ignore stop */ initOnUserInteraction() { const interactionEvents = [ "mouseenter", "mousemove", "touchstart", "scroll", "keydown", "click" ]; const handleUserInteraction = () => { if (this.isInitialized) return; this.removeInteractionListeners(); this.scheduleInitialization(); }; interactionEvents.forEach((eventType) => { const listener = handleUserInteraction; document.addEventListener(eventType, listener, { passive: true, once: true // Automatically removes after first trigger }); this.userInteractionListeners.push({ event: eventType, handler: listener }); }); } removeInteractionListeners() { this.userInteractionListeners.forEach(({ event, handler }) => { document.removeEventListener(event, handler); }); this.userInteractionListeners = []; } scheduleInitialization() { if (this.isInitialized) return; this.isInitialized = true; const initializeComponent = () => { const pathname = window.location.pathname; this.initializeAsync(pathname).catch((error) => { console.error("UserQuickTools initialization failed:", error); }); }; if ("requestIdleCallback" in window) { requestIdleCallback(initializeComponent, { timeout: 1e3 }); } else { setTimeout(initializeComponent, 0); } } async initializeAsync(pathname) { try { const sessionData = await this.getSession(); if (!sessionData?.isLoggedIn) { return; } if (isDashboardRoute(pathname, sessionData.routes.dashboardIndex)) { return; } this.sessionData = sessionData; this.scheduleRender(); } catch (error) { console.warn("UserQuickTools failed to initialize:", error); } } /* v8 ignore start */ scheduleRender() { const renderComponent = () => { this.render(); this.setupEventListeners(); this.setupThemeObserver(); }; requestAnimationFrame(() => { if ("requestIdleCallback" in window) { requestIdleCallback(renderComponent, { timeout: 500 }); } else { setTimeout(renderComponent, 0); } }); } disconnectedCallback() { this.cleanup(); this.removeInteractionListeners(); } async render() { if (!this.shadowRoot || !this.sessionData) return; this.menuOverlay = document.createElement("div"); this.menuOverlay.className = "menu_overlay"; this.cornerMenu = document.createElement("div"); this.cornerMenu.className = "cornerMenu"; this.cornerMenu.dataset.theme = document.documentElement.dataset.theme ?? "dark"; const styleElm = document.createElement("style"); styleElm.textContent = COMPONENT_STYLES; this.addMenuItems(); this.shadowRoot.append(styleElm, this.menuOverlay, this.cornerMenu); void this.addUserAvatar().catch((e) => console.warn("Avatar load failed:", e)); } addMenuItems() { if (!this.cornerMenu || !this.sessionData) return; const { routes, permissionLevel } = this.sessionData; const routeMap = { Logout: routes.logout, Profile: routes.userProfile, Dashboard: routes.dashboardIndex, Edit: routes.contentManagement }; UserQuickTools.MENU_ITEMS.forEach((item) => { if (verifyUserPermissionLevel(permissionLevel, item.permission)) { const menuElement = this.createMenuElement({ ...item, href: routeMap[item.name] }); this.cornerMenu.appendChild(menuElement); } }); } createMenuElement(item) { if (item.name === "Logout") { return this.createLogoutElement(item); } const element = document.createElement("a"); element.className = `menu ${item.cssClass}`; element.title = item.name; const parser = new DOMParser(); const svgDoc = parser.parseFromString(item.svg, "image/svg+xml"); const svgElement = svgDoc.documentElement; if (svgElement && svgElement.nodeName === "svg") { element.appendChild(svgElement.cloneNode(true)); } else { console.warn("Invalid SVG content for menu item:", item.name); } element.href = item.href; element.addEventListener("click", (e) => { const timeSinceToggle = Date.now() - this.lastMenuToggleTime; if (!this.menuItemsReady || timeSinceToggle < this.CLICK_PROTECTION_DURATION) { e.preventDefault(); e.stopPropagation(); element.classList.add("click-ignored"); setTimeout(() => element.classList.remove("click-ignored"), 300); return false; } }); return element; } createLogoutElement(item) { const element = document.createElement("a"); element.className = `menu ${item.cssClass}`; element.title = item.name; element.href = "#"; const parser = new DOMParser(); const svgDoc = parser.parseFromString(item.svg, "image/svg+xml"); const svgElement = svgDoc.documentElement; if (svgElement && svgElement.nodeName === "svg") { element.appendChild(svgElement.cloneNode(true)); } element.addEventListener("click", (e) => { e.preventDefault(); const timeSinceToggle = Date.now() - this.lastMenuToggleTime; if (!this.menuItemsReady || timeSinceToggle < this.CLICK_PROTECTION_DURATION) { element.classList.add("click-ignored"); setTimeout(() => element.classList.remove("click-ignored"), 300); return; } this.submitLogoutForm(item.href); }); return element; } submitLogoutForm(logoutUrl) { const form = document.createElement("form"); form.method = "POST"; form.action = logoutUrl; form.style.display = "none"; document.body.appendChild(form); form.submit(); } async testAvatarURL(url) { let urlObj; try { urlObj = new URL(url); } catch { console.warn("Invalid avatar URL:", url); return void 0; } if (urlObj.protocol !== "https:") { console.error(`Insecure avatar URL protocol: ${urlObj.protocol}`); return void 0; } const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 4e3); try { const response = await fetch(url, { method: "HEAD", signal: controller.signal, cache: "no-cache" }); if (!response.ok) return void 0; const contentType = (response.headers.get("content-type") || "").split(";")[0].trim(); if (!contentType.startsWith("image/")) return void 0; if (contentType === "image/svg+xml") { console.error("Remote SVG avatars are disallowed for security."); return void 0; } return { type: contentType }; } catch (err) { console.warn("Avatar HEAD check failed:", err); return void 0; } finally { clearTimeout(timeoutId); } } async addUserAvatar() { if (!this.cornerMenu || !this.sessionData) return; const { user, permissionLevel } = this.sessionData; const avatarContainer = document.createElement("div"); avatarContainer.className = "avatar-container"; const newAvatar = document.createElement("img"); const avatar = await (async () => { let avatar2 = DEFAULT_AVATAR; if (user.avatar) { const result = await this.testAvatarURL(user.avatar); if (result) { avatar2 = user.avatar; } } return avatar2; })(); newAvatar.src = avatar; newAvatar.width = 64; newAvatar.height = 64; newAvatar.className = "avatar"; newAvatar.alt = `${user.name} - ${this.capitalizeFirst(permissionLevel)}`; newAvatar.loading = "lazy"; newAvatar.decoding = "async"; newAvatar.referrerPolicy = "no-referrer"; if (avatar === DEFAULT_AVATAR) { newAvatar.classList.add("avatar-error"); } newAvatar.onerror = function() { this.src = DEFAULT_AVATAR; }; newAvatar.setAttribute("aria-hidden", "true"); avatarContainer.appendChild(newAvatar); this.cornerMenu.appendChild(avatarContainer); } setupEventListeners() { if (!this.cornerMenu || !this.menuOverlay) return; this.cornerMenu.addEventListener("click", this.handleMenuToggle.bind(this)); this.menuOverlay.addEventListener("click", this.handleOverlayClick.bind(this)); } handleMenuToggle() { this.lastMenuToggleTime = Date.now(); this.isMenuOpen = !this.isMenuOpen; if (this.isMenuOpen) { this.menuItemsReady = false; this.cornerMenu?.classList.remove("menu-ready"); this.readyTimeout = window.setTimeout(() => { this.menuItemsReady = true; this.cornerMenu?.classList.add("menu-ready"); }, this.MENU_READY_DELAY); } else { this.menuItemsReady = false; this.cornerMenu?.classList.remove("menu-ready"); if (this.readyTimeout) { clearTimeout(this.readyTimeout); this.readyTimeout = null; } } this.updateMenuState(); } handleOverlayClick() { if (this.isMenuOpen) { this.isMenuOpen = false; this.menuItemsReady = false; this.cornerMenu?.classList.remove("menu-ready"); if (this.readyTimeout) { clearTimeout(this.readyTimeout); this.readyTimeout = null; } this.updateMenuState(); } } updateMenuState() { if (!this.cornerMenu || !this.menuOverlay) return; const method = this.isMenuOpen ? "add" : "remove"; this.cornerMenu.classList[method]("menuOpened"); this.menuOverlay.classList[method]("menuOpened"); } setupThemeObserver() { if (!this.cornerMenu) return; const updateTheme = () => { const theme = document.documentElement.getAttribute("data-theme"); this.cornerMenu.dataset.theme = theme === "light" ? "light" : "dark"; }; this.themeObserver = new MutationObserver(updateTheme); this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); } /* v8 ignore stop */ async getSession() { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5e3); const response = await fetch("/studiocms_api/dashboard/verify-session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ originPathname: window.location.toString() }), signal: controller.signal }); clearTimeout(timeoutId); return response.ok ? await response.json() : null; } catch (error) { if (error instanceof Error && error.name === "AbortError") { console.warn("Session verification timed out"); } else { console.warn("Session verification failed:", error); } return null; } } /* v8 ignore start */ capitalizeFirst(str) { return str.charAt(0).toUpperCase() + str.slice(1); } cleanup() { this.themeObserver?.disconnect(); this.themeObserver = null; if (this.readyTimeout) { clearTimeout(this.readyTimeout); this.readyTimeout = null; } this.cornerMenu = null; this.menuOverlay = null; this.sessionData = null; this.isInitialized = false; this.menuItemsReady = false; this.lastMenuToggleTime = 0; } /* v8 ignore stop */ } class ConfigurableUserQuickTools extends UserQuickTools { config; constructor() { super(); this.config = { strategy: this.getAttribute("data-init-strategy") || "interaction", timeout: Number.parseInt(this.getAttribute("data-timeout") || "1000", 10), clickProtectionDuration: Number.parseInt( this.getAttribute("data-click-protection") || "400", 10 ), menuReadyDelay: Number.parseInt(this.getAttribute("data-menu-delay") || "350", 10) }; if (this.config.clickProtectionDuration) { this.CLICK_PROTECTION_DURATION = this.config.clickProtectionDuration; } if (this.config.menuReadyDelay) { this.MENU_READY_DELAY = this.config.menuReadyDelay; } } connectedCallback() { const pathname = window.location.pathname; if (shouldSkipRendering(pathname)) { return; } switch (this.config.strategy) { case "immediate": this.scheduleInitialization(); break; /* v8 ignore stop */ case "idle": if ("requestIdleCallback" in window) { requestIdleCallback(() => this.scheduleInitialization(), { timeout: this.config.timeout }); } else { setTimeout(() => this.scheduleInitialization(), 0); } break; /* v8 ignore stop */ case "interaction": this.initOnUserInteraction(); break; default: console.warn(`Unknown initialization strategy: ${this.config.strategy}`); this.initOnUserInteraction(); break; } } } if ("customElements" in window && !customElements.get("user-quick-tools")) { customElements.define("user-quick-tools", ConfigurableUserQuickTools); } function initializeWhenReady() { const createElement = () => { if (!document.querySelector("user-quick-tools")) { const element = document.createElement("user-quick-tools"); element.setAttribute("data-init-strategy", "idle"); element.setAttribute("data-timeout", "1000"); element.setAttribute("data-click-protection", "400"); element.setAttribute("data-menu-delay", "350"); document.body.appendChild(element); } }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => { setTimeout(createElement, 0); }); } else { setTimeout(createElement, 0); } } initializeWhenReady(); export { COMPONENT_STYLES, ConfigurableUserQuickTools, DEFAULT_AVATAR, KNOWN_API_ROUTES, PERMISSION_HIERARCHY, UserQuickTools, initializeWhenReady, isDashboardRoute, shouldSkipRendering, verifyUserPermissionLevel };