UNPKG

studiocms

Version:

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

648 lines (570 loc) 18.9 kB
/** biome-ignore-all lint/style/noNonNullAssertion: This file is using non-null assertions */ /** biome-ignore-all lint/suspicious/noExplicitAny: This file is using explicit any types */ const configElement = document.getElementById('auth-pages-config'); const loginPageBackground = configElement instanceof HTMLDivElement ? configElement.dataset.config_background : undefined; const loginPageCustomImage = configElement instanceof HTMLDivElement ? configElement.dataset.config_custom_image : undefined; const currentMode = (document.documentElement as HTMLElement | null)?.dataset?.theme === 'light' ? 'light' : 'dark'; const studioCMS3DModel = 'https://cdn.studiocms.dev/studiocms-logo.glb'; /** * A valid image that can be used as a background for the StudioCMS Logo. */ interface ValidImage { readonly name: string; readonly label: string; readonly format: 'local' | 'web'; readonly light: { src: string } | null; readonly dark: { src: string } | null; } /** * The parameters for the background image. */ type BackgroundParams = { background: string; customImageHref: string; mode: 'light' | 'dark'; }; /** * Parses the background image config. * @param imageName The name of the image to parse. */ function parseBackgroundImageConfig(imageName?: string | undefined): string { return imageName || 'studiocms-curves'; } function parseToString(value: string | undefined | null): string { return value || ''; } /** * The parameters for the background image config. */ const backgroundConfig: BackgroundParams = { background: parseBackgroundImageConfig(loginPageBackground), customImageHref: parseToString(loginPageCustomImage), mode: currentMode as 'light' | 'dark', }; /** * Gets the background config based on the parameters. */ function getBackgroundConfig(config: BackgroundParams, validImages: ValidImage[]): ValidImage { return validImages.find((image) => image.name === config.background) || validImages[0]; } /** * Selects the background based on the image. */ function bgSelector(image: ValidImage, params: BackgroundParams) { return image.format === 'web' ? params.customImageHref : params.mode === 'dark' ? image.dark?.src : image.light?.src; } /** * Lazy-loading wrapper for the StudioCMS 3D Logo */ class LazyStudioCMS3DLogo { private container: HTMLDivElement; private observer: IntersectionObserver; private loaded = false; private logoInstance: StudioCMS3DLogo | null = null; private destroyed = false; constructor(containerEl: HTMLDivElement) { this.container = containerEl; // Only load Three.js when the container comes into view 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); } private async loadThreeJS() { try { // Show loading state with some basic styling this.showLoadingState(); // Dynamic import of all required modules 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; // Clear loading state this.container.replaceChildren(); // Create the modules object to pass to StudioCMS3DLogo const modules = { ...threeModule, OutlinePass, GLTFLoader, RenderPass, EffectComposer, validImages, fitModelToViewport, }; // Initialize the 3D logo this.logoInstance = new StudioCMS3DLogo( this.container, new threeModule.Color(0xaa87f4), window.matchMedia('(prefers-reduced-motion: reduce)').matches, getBackgroundConfig(backgroundConfig, validImages), modules ); } catch (error) { console.error('Failed to load 3D experience:', error); this.showErrorState(); } } private 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); } private 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 = '⚠️'; 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'); // Still mark as "loaded" to prevent layout shifts } destroy() { this.destroyed = true; this.observer.disconnect(); if (this.logoInstance) { this.logoInstance.dispose?.(); this.logoInstance = null; } } } /** * Enhanced StudioCMS3DLogo that accepts modules as dependency injection */ class StudioCMS3DLogo { canvasContainer: HTMLDivElement; scene: any; camera: any; renderer: any; model: any | undefined; mouseX = 0; mouseY = 0; composer: any; outlinePass: any | undefined; outlinedObjects: any[] = []; defaultComputedCameraZ: number | undefined; BackgroundMesh: any | undefined; frustumHeight: number | undefined; frames = 0; fps = 0; lastTime = 0; lastFrameTimes: number[] = []; MAX_FRAME_TIMES_LENGTH = 2; private resizeHandler?: () => void; private mouseMoveHandler?: (ev: MouseEvent) => void; private prevLoadingOnLoad?: (() => void) | null; // Track background texture aspect ratio to recompute geometry on resize private bgAspect?: number; private loadingManager?: any; // Cache materials to avoid recreating them private glassMaterial: any | undefined; private modules: any; constructor( containerEl: HTMLDivElement, outlineColor: any, reducedMotion: boolean, image: ValidImage, modules: any ) { this.modules = modules; const { Scene, PerspectiveCamera, WebGLRenderer, Color, AmbientLight, RenderPass, EffectComposer, LoadingManager, } = modules; this.scene = new Scene(); this.scene.background = new Color(0x101010); this.camera = new PerspectiveCamera( 75, window.innerWidth / 2 / window.innerHeight, 0.01, 10000 ); // Optimize renderer settings 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(0x101010, 1); this.renderer.setAnimationLoop(this.animate); this.canvasContainer = containerEl; this.canvasContainer.appendChild(this.renderer.domElement); // Local LoadingManager to avoid global side effects this.loadingManager = new LoadingManager(); this.loadingManager.onLoad = () => { this.canvasContainer.classList.add('loaded'); }; this.composer = new EffectComposer(this.renderer); // Use real container size for a correct initial fit 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); } // Simplified lighting const light2 = new AmbientLight(0x606060); 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 >= 1000) { 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) { // Graceful degrade for low-end devices this.renderer?.setAnimationLoop(null); try { this.dispose(); } finally { this.canvasContainer?.classList.add('loaded'); } return; } } }; animate = () => { if (this.model && this.canvasContainer) { const { MathUtils } = this.modules; // Movement courtesy of Otterlord, easing courtesy of Louis Escher 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(); }; private 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; // disposed this.model = gltf.scene; const material = this.getGlassMaterial(); this.model.traverse((child: any) => { 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: boolean, outlineColor: any) => { if (outlines) this.addOutlines(outlineColor); }; addOutlines = (outlineColor: any) => { 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 = 0.0000000001; this.outlinePass.pulsePeriod = 0; this.outlinePass.visibleEdgeColor.set(outlineColor); this.outlinePass.hiddenEdgeColor.set(new Color(0xffffff)); this.composer.addPass(this.outlinePass); }; addBackgroundImage = async (image: ValidImage) => { 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; // disposed const planeHeight = this.frustumHeight!; const srcW = (texture as any).source?.data?.width ?? (texture as any).image?.width; const srcH = (texture as any).source?.data?.height ?? (texture as any).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: boolean) => { 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); // Recompute background plane size (preserving aspect) if (this.BackgroundMesh && this.bgAspect) { const { PlaneGeometry, MathUtils } = this.modules; const meshZ = this.BackgroundMesh.position?.z ?? -5; // 9 is camera "height" factor from original computation 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; // Replace geometry to match new size 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?.(); // Restore global loading manager handler try { const { DefaultLoadingManager } = this.modules; if (this.prevLoadingOnLoad !== undefined) { DefaultLoadingManager.onLoad = this.prevLoadingOnLoad as any; 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 = undefined; } if (this.model) { this.model.traverse((child: any) => { child.geometry?.dispose?.(); child.material?.map?.dispose?.(); child.material?.dispose?.(); }); this.scene.remove(this.model); this.model = undefined; } this.glassMaterial?.dispose?.(); this.renderer?.forceContextLoss?.(); this.renderer?.dispose?.(); this.renderer?.domElement?.remove?.(); }; registerLoadingCallback = () => { // No-op when using a local LoadingManager; retained for backward-compat }; recomputeGlassMaterial = () => { if (!this.model) return; const { Mesh } = this.modules; const material = this.getGlassMaterial(); this.model.traverse((child: any) => { if (child instanceof Mesh) { child.material = material; } }); }; } // Initialize the lazy-loading 3D logo const logoContainer = document.querySelector<HTMLDivElement>('#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'); }