UNPKG

studiocms

Version:

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

485 lines (484 loc) 17.1 kB
const configElement = document.getElementById("auth-pages-config"); const loginPageBackground = configElement instanceof HTMLDivElement ? configElement.dataset.config_background : void 0; const loginPageCustomImage = configElement instanceof HTMLDivElement ? configElement.dataset.config_custom_image : void 0; const currentMode = document.documentElement?.dataset?.theme === "light" ? "light" : "dark"; const studioCMS3DModel = "https://cdn.studiocms.dev/studiocms-logo.glb"; function parseBackgroundImageConfig(imageName) { return imageName || "studiocms-curves"; } function parseToString(value) { return value || ""; } const backgroundConfig = { background: parseBackgroundImageConfig(loginPageBackground), customImageHref: parseToString(loginPageCustomImage), mode: currentMode }; function getBackgroundConfig(config, validImages) { return validImages.find((image) => image.name === config.background) || validImages[0]; } function bgSelector(image, params) { return image.format === "web" ? params.customImageHref : params.mode === "dark" ? image.dark?.src : image.light?.src; } class LazyStudioCMS3DLogo { container; observer; loaded = false; logoInstance = null; destroyed = false; constructor(containerEl) { this.container = containerEl; this.observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting && !this.loaded) { this.loaded = true; this.observer.disconnect(); void this.loadThreeJS(); } }); }, { rootMargin: "100px" } // Start loading 100px before it comes into view ); this.observer.observe(this.container); } async loadThreeJS() { try { this.showLoadingState(); const [ threeModule, { OutlinePass }, { GLTFLoader }, { RenderPass }, { EffectComposer }, { validImages }, { fitModelToViewport } ] = await Promise.all([ import("three"), import("three/addons/postprocessing/OutlinePass.js"), import("three/addons/loaders/GLTFLoader.js"), import("three/addons/postprocessing/RenderPass.js"), import("three/addons/postprocessing/EffectComposer.js"), import("../validImages/index.js"), import("./utils/fitModelToViewport.js") ]); if (this.destroyed || !this.container.isConnected) return; this.container.replaceChildren(); const modules = { ...threeModule, OutlinePass, GLTFLoader, RenderPass, EffectComposer, validImages, fitModelToViewport }; this.logoInstance = new StudioCMS3DLogo( this.container, new threeModule.Color(11175924), window.matchMedia("(prefers-reduced-motion: reduce)").matches, getBackgroundConfig(backgroundConfig, validImages), modules ); } catch (error) { console.error("Failed to load 3D experience:", error); this.showErrorState(); } } showLoadingState() { this.container.replaceChildren(); const wrap = document.createElement("div"); wrap.className = "loading-3d"; Object.assign(wrap.style, { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", minHeight: "400px", color: "var(--text-color, #666)", fontFamily: "system-ui, sans-serif" }); const spinner = document.createElement("div"); spinner.className = "loading-spinner"; Object.assign(spinner.style, { width: "40px", height: "40px", border: "3px solid var(--border-color, #e0e0e0)", borderTop: "3px solid var(--accent-color, #aa87f4)", borderRadius: "50%", marginBottom: "16px", animation: "spin 1s linear infinite" }); const label = document.createElement("p"); label.textContent = "Loading 3D experience..."; label.style.margin = "0"; label.style.fontSize = "14px"; const keyframes = document.createElement("style"); keyframes.textContent = "@keyframes spin {0%{transform:rotate(0deg);}100%{transform:rotate(360deg);}}"; wrap.append(spinner, label); this.container.append(wrap, keyframes); } showErrorState() { this.container.replaceChildren(); const wrap = document.createElement("div"); wrap.className = "error-3d"; Object.assign(wrap.style, { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", minHeight: "400px", color: "var(--text-color, #666)", fontFamily: "system-ui, sans-serif" }); const icon = document.createElement("div"); icon.textContent = "\u26A0\uFE0F"; icon.style.fontSize = "24px"; icon.style.marginBottom = "8px"; icon.style.opacity = "0.5"; const label = document.createElement("p"); label.textContent = "3D experience unavailable"; label.style.margin = "0"; label.style.fontSize = "14px"; wrap.append(icon, label); this.container.append(wrap); this.container.classList.add("loaded"); } destroy() { this.destroyed = true; this.observer.disconnect(); if (this.logoInstance) { this.logoInstance.dispose?.(); this.logoInstance = null; } } } class StudioCMS3DLogo { canvasContainer; scene; camera; renderer; model; mouseX = 0; mouseY = 0; composer; outlinePass; outlinedObjects = []; defaultComputedCameraZ; BackgroundMesh; frustumHeight; frames = 0; fps = 0; lastTime = 0; lastFrameTimes = []; MAX_FRAME_TIMES_LENGTH = 2; resizeHandler; mouseMoveHandler; prevLoadingOnLoad; // Track background texture aspect ratio to recompute geometry on resize bgAspect; loadingManager; // Cache materials to avoid recreating them glassMaterial; modules; constructor(containerEl, outlineColor, reducedMotion, image, modules) { this.modules = modules; const { Scene, PerspectiveCamera, WebGLRenderer, Color, AmbientLight, RenderPass, EffectComposer, LoadingManager } = modules; this.scene = new Scene(); this.scene.background = new Color(1052688); this.camera = new PerspectiveCamera( 75, window.innerWidth / 2 / window.innerHeight, 0.01, 1e4 ); this.renderer = new WebGLRenderer({ antialias: false, powerPreference: "high-performance", stencil: false, depth: true }); this.renderer.setSize(window.innerWidth / 2, window.innerHeight); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.setClearColor(1052688, 1); this.renderer.setAnimationLoop(this.animate); this.canvasContainer = containerEl; this.canvasContainer.appendChild(this.renderer.domElement); this.loadingManager = new LoadingManager(); this.loadingManager.onLoad = () => { this.canvasContainer.classList.add("loaded"); }; this.composer = new EffectComposer(this.renderer); const rect = containerEl.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { this.camera.aspect = rect.width / rect.height; this.camera.updateProjectionMatrix(); this.renderer.setSize(rect.width, rect.height); this.composer.setSize(rect.width, rect.height); } const light2 = new AmbientLight(6316128); this.scene.add(light2); const renderScene = new RenderPass(this.scene, this.camera); this.composer.addPass(renderScene); this.registerLoadingCallback(); this.loadLogoModel(); this.addPostProcessing(true, outlineColor); this.addBackgroundImage(image); this.initListeners(reducedMotion); this.lastTime = performance.now(); this.updateFPS(); } updateFPS = () => { const now = performance.now(); this.frames++; if (now - this.lastTime >= 1e3) { this.fps = this.frames; this.frames = 0; this.lastTime = now; if (this.lastFrameTimes.length >= this.MAX_FRAME_TIMES_LENGTH) { this.lastFrameTimes.shift(); } this.lastFrameTimes.push(this.fps); } if (this.lastFrameTimes.length === this.MAX_FRAME_TIMES_LENGTH) { const averageFPS = this.lastFrameTimes.reduce((a, b) => a + b, 0) / this.MAX_FRAME_TIMES_LENGTH; if (averageFPS < 24) { this.renderer?.setAnimationLoop(null); try { this.dispose(); } finally { this.canvasContainer?.classList.add("loaded"); } return; } } }; animate = () => { if (this.model && this.canvasContainer) { const { MathUtils } = this.modules; const viewportWidth = this.renderer?.domElement?.clientWidth || this.canvasContainer?.clientWidth || window.innerWidth; const viewportHeight = this.renderer?.domElement?.clientHeight || this.canvasContainer?.clientHeight || window.innerHeight; const targetRotationX = this.mouseY === 0 ? Math.PI / 2 : 0.1 * (this.mouseY / viewportHeight * Math.PI - Math.PI / 2) + Math.PI / 2; const targetRotationZ = this.mouseX === 0 ? 0 : 0.1 * (this.mouseX / viewportWidth * Math.PI - Math.PI / 2); const lerpFactor = 0.035; this.model.rotation.x = MathUtils.lerp(this.model.rotation.x, targetRotationX, lerpFactor); this.model.rotation.y = MathUtils.lerp(this.model.rotation.y, 0, lerpFactor); this.model.rotation.z = MathUtils.lerp(this.model.rotation.z, -targetRotationZ, lerpFactor); } this.composer.render(); this.updateFPS(); }; getGlassMaterial() { if (!this.glassMaterial) { const { MeshPhysicalMaterial } = this.modules; this.glassMaterial = new MeshPhysicalMaterial({ color: "#ffffff", roughness: 0.6, transmission: 1, opacity: 1, transparent: true, thickness: 0.5, envMapIntensity: 1, clearcoat: 1, clearcoatRoughness: 0.2, metalness: 0 }); } return this.glassMaterial; } loadLogoModel = async () => { const { GLTFLoader, Mesh, fitModelToViewport } = this.modules; const loader = new GLTFLoader(this.loadingManager); try { const gltf = await loader.loadAsync(studioCMS3DModel); if (!this.renderer) return; this.model = gltf.scene; const material = this.getGlassMaterial(); this.model.traverse((child) => { if (child instanceof Mesh) { child.material = material; } }); this.scene.add(this.model); this.model.rotation.set(Math.PI / 2, 0, 0); this.defaultComputedCameraZ = fitModelToViewport(this.model, this.camera); this.outlinedObjects.push(this.model); } catch (error) { console.error("Failed to load logo model:", error); this.canvasContainer?.classList.add("loaded"); } }; addPostProcessing = (outlines, outlineColor) => { if (outlines) this.addOutlines(outlineColor); }; addOutlines = (outlineColor) => { const { OutlinePass, Vector2, Color } = this.modules; this.outlinePass = new OutlinePass( new Vector2(window.innerWidth / 2, window.innerHeight), this.scene, this.camera ); this.outlinePass.selectedObjects = this.outlinedObjects; this.outlinePass.edgeStrength = 1.5; this.outlinePass.edgeGlow = 0; this.outlinePass.edgeThickness = 1e-10; this.outlinePass.pulsePeriod = 0; this.outlinePass.visibleEdgeColor.set(outlineColor); this.outlinePass.hiddenEdgeColor.set(new Color(16777215)); this.composer.addPass(this.outlinePass); }; addBackgroundImage = async (image) => { const { TextureLoader, PlaneGeometry, MeshBasicMaterial, Mesh, MathUtils } = this.modules; const bgPositionZ = -5; if (!this.frustumHeight) { this.frustumHeight = 9 * Math.tan(MathUtils.degToRad(this.camera.fov / 2)) * Math.abs(this.camera.position.z - bgPositionZ); } const loader = new TextureLoader(this.loadingManager); const bgUrl = bgSelector(image, backgroundConfig); if (!bgUrl) { console.error("ERROR: Invalid background URL"); return; } try { const texture = await loader.loadAsync(bgUrl); if (!this.renderer) return; const planeHeight = this.frustumHeight; const srcW = texture.source?.data?.width ?? texture.image?.width; const srcH = texture.source?.data?.height ?? texture.image?.height; if (!srcW || !srcH) { console.error("ERROR: Unable to determine background texture dimensions"); return; } this.bgAspect = srcW / srcH; const planeWidth = planeHeight * this.bgAspect; const bgGeo = new PlaneGeometry(planeWidth, planeHeight); const bgMat = new MeshBasicMaterial({ map: texture }); this.BackgroundMesh = new Mesh(bgGeo, bgMat); this.BackgroundMesh.position.set(0, 0, bgPositionZ); this.scene.add(this.BackgroundMesh); } catch (error) { console.error("Failed to load background image:", error); } }; initListeners = (reducedMotion) => { this.initResizeListener(); if (!reducedMotion) { this.initMouseMoveListener(); } }; initResizeListener = () => { this.resizeHandler = () => { if (window.innerWidth > 850) { const rect = this.canvasContainer.getBoundingClientRect(); const width = rect.width || window.innerWidth / 2; const height = rect.height || window.innerHeight; this.camera.aspect = width / height; this.camera.updateProjectionMatrix(); this.renderer.setSize(width, height); this.composer.setSize(width, height); if (this.outlinePass?.setSize) this.outlinePass.setSize(width, height); if (this.BackgroundMesh && this.bgAspect) { const { PlaneGeometry, MathUtils } = this.modules; const meshZ = this.BackgroundMesh.position?.z ?? -5; this.frustumHeight = 9 * Math.tan(MathUtils.degToRad(this.camera.fov / 2)) * Math.abs(this.camera.position.z - meshZ); const newPlaneHeight = this.frustumHeight; const newPlaneWidth = newPlaneHeight * this.bgAspect; this.BackgroundMesh.geometry?.dispose?.(); this.BackgroundMesh.geometry = new PlaneGeometry(newPlaneWidth, newPlaneHeight); } if (window.innerWidth < 1100 && this.defaultComputedCameraZ) { this.camera.position.set( this.camera.position.x, this.camera.position.y, this.defaultComputedCameraZ + 5 ); } else if (window.innerWidth >= 1100 && this.defaultComputedCameraZ) { this.camera.position.set( this.camera.position.x, this.camera.position.y, this.defaultComputedCameraZ ); } } }; window.addEventListener("resize", this.resizeHandler); }; initMouseMoveListener = () => { this.mouseMoveHandler = (ev) => { this.mouseX = ev.clientX; this.mouseY = ev.clientY; }; document.addEventListener("mousemove", this.mouseMoveHandler); }; dispose = () => { this.renderer?.setAnimationLoop(null); if (this.resizeHandler) window.removeEventListener("resize", this.resizeHandler); if (this.mouseMoveHandler) document.removeEventListener("mousemove", this.mouseMoveHandler); if (this.outlinePass?.dispose) this.outlinePass.dispose(); this.composer?.dispose?.(); try { const { DefaultLoadingManager } = this.modules; if (this.prevLoadingOnLoad !== void 0) { DefaultLoadingManager.onLoad = this.prevLoadingOnLoad; this.prevLoadingOnLoad = null; } } catch { } if (this.BackgroundMesh) { this.BackgroundMesh.material?.map?.dispose?.(); this.BackgroundMesh.material?.dispose?.(); this.BackgroundMesh.geometry?.dispose?.(); this.scene.remove(this.BackgroundMesh); this.BackgroundMesh = void 0; } if (this.model) { this.model.traverse((child) => { child.geometry?.dispose?.(); child.material?.map?.dispose?.(); child.material?.dispose?.(); }); this.scene.remove(this.model); this.model = void 0; } this.glassMaterial?.dispose?.(); this.renderer?.forceContextLoss?.(); this.renderer?.dispose?.(); this.renderer?.domElement?.remove?.(); }; registerLoadingCallback = () => { }; recomputeGlassMaterial = () => { if (!this.model) return; const { Mesh } = this.modules; const material = this.getGlassMaterial(); this.model.traverse((child) => { if (child instanceof Mesh) { child.material = material; } }); }; } const logoContainer = document.querySelector("#canvas-container"); const smallScreen = window.matchMedia("(max-width: 850px)").matches; if (logoContainer && !smallScreen) { try { new LazyStudioCMS3DLogo(logoContainer); } catch (err) { console.error("ERROR: Couldn't create LazyStudioCMS3DLogo", err); logoContainer.classList.add("loaded"); } } else if (logoContainer) { logoContainer.classList.add("loaded"); }