studiocms
Version:
Astro Native CMS for AstroDB. Built from the ground up by the Astro community.
485 lines (484 loc) • 17.1 kB
JavaScript
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");
}