Compare commits

...

7 Commits

Author SHA1 Message Date
Zimeng Xiong 8fcca43b0d chore: pre-release v0.4.4-dev 2026-02-07 11:58:09 -08:00
Zimeng Xiong f20412cdfb separate debounced autosave 2026-02-07 11:57:32 -08:00
Zimeng Xiong a366acfedc chore: pre-release v0.4.3-dev 2026-02-07 11:08:03 -08:00
Zimeng Xiong 154dcbb151 update resopnsiveness hamburger 2026-02-07 11:07:15 -08:00
Zimeng Xiong 2e74d2ad1a chore: pre-release v0.4.2-dev 2026-02-07 10:34:36 -08:00
Zimeng Xiong 173c050f58 fix HTTPS reuqirement when frontend URL is nto HTTPS 2026-02-07 10:31:08 -08:00
Zimeng Xiong 8161a563f0 chore: pre-release v0.4.1-dev 2026-02-07 10:08:27 -08:00
8 changed files with 76 additions and 65 deletions
+1 -1
View File
@@ -1 +1 @@
0.4.1
0.4.4
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "backend",
"version": "0.4.0",
"version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
"version": "0.4.0",
"version": "0.4.1",
"license": "ISC",
"dependencies": {
"@prisma/client": "^5.22.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "0.4.1",
"version": "0.4.4",
"description": "",
"main": "index.js",
"scripts": {
+6 -2
View File
@@ -259,8 +259,12 @@ app.use((req, res, next) => {
next();
});
// HTTPS enforcement in production
if (config.nodeEnv === "production") {
// HTTPS enforcement in production only when configured frontend origins use HTTPS.
const shouldEnforceHttps =
config.nodeEnv === "production" &&
allowedOrigins.some((origin) => origin.toLowerCase().startsWith("https://"));
if (shouldEnforceHttps) {
app.use((req, res, next) => {
if (req.header("x-forwarded-proto") !== "https") {
res.redirect(`https://${req.header("host")}${req.url}`);
+12 -25
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "0.3.2",
"version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.3.2",
"version": "0.4.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -162,7 +162,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -517,7 +516,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -561,7 +559,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2612,7 +2609,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -2777,7 +2775,6 @@
"integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -2789,7 +2786,6 @@
"integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/react": "*"
}
@@ -2860,7 +2856,6 @@
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.47.0",
"@typescript-eslint/types": "8.47.0",
@@ -3224,7 +3219,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3275,6 +3269,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -3504,7 +3499,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -3840,7 +3834,6 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10"
}
@@ -4214,7 +4207,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -4454,7 +4446,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/dompurify": {
"version": "3.1.6",
@@ -4673,7 +4666,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5507,7 +5499,6 @@
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz",
"integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.20.0"
},
@@ -5855,6 +5846,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -6884,7 +6876,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
@@ -7050,6 +7041,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -7065,6 +7057,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -7120,7 +7113,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -7133,7 +7125,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -7147,7 +7138,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-refresh": {
"version": "0.18.0",
@@ -7862,7 +7854,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -8001,7 +7992,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -8191,7 +8181,6 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -8285,7 +8274,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -8612,7 +8600,6 @@
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.4.1",
"version": "0.4.4",
"type": "module",
"scripts": {
"dev": "vite --port 6767",
+9 -2
View File
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Menu, X } from 'lucide-react';
import { Sidebar } from './Sidebar';
import { Logo } from './Logo';
import { UploadStatus } from './UploadStatus';
import type { Collection } from '../types';
import clsx from 'clsx';
@@ -89,16 +90,22 @@ export const Layout: React.FC<LayoutProps> = ({
{isMobile ? (
<div className="relative h-full min-w-0">
<main className="h-full min-w-0 bg-white/40 dark:bg-neutral-900/40 backdrop-blur-sm rounded-2xl border border-white/50 dark:border-neutral-800/50 shadow-sm transition-colors duration-200 overflow-hidden flex flex-col">
<div className="px-3 pt-3 flex-shrink-0">
<div className="h-16 flex-shrink-0 flex items-center px-4 border-b border-black/5 dark:border-white/5 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-md">
<button
type="button"
onClick={() => setIsSidebarOpen(v => !v)}
className="inline-flex items-center justify-center h-11 w-11 rounded-xl border-2 border-black dark:border-neutral-700 bg-white/90 dark:bg-neutral-900/90 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] backdrop-blur-sm text-slate-900 dark:text-neutral-200 hover:-translate-y-0.5 transition-all"
className="inline-flex items-center justify-center h-11 w-11 rounded-xl border-2 border-black dark:border-neutral-700 bg-white/90 dark:bg-neutral-900/90 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] text-slate-900 dark:text-neutral-200 hover:-translate-y-0.5 transition-all active:translate-y-0 active:shadow-none"
title={isSidebarOpen ? 'Close menu' : 'Open menu'}
aria-label={isSidebarOpen ? 'Close menu' : 'Open menu'}
>
{isSidebarOpen ? <X size={20} /> : <Menu size={20} />}
</button>
<div className="ml-auto flex items-center gap-2">
<Logo className="w-8 h-8" />
<span className="text-xl text-slate-900 dark:text-white mt-1" style={{ fontFamily: 'Excalifont' }}>ExcaliDash</span>
<span className="text-[10px] font-bold text-red-500 mt-2" style={{ fontFamily: 'sans-serif' }}>BETA</span>
</div>
</div>
<div className="flex-1 min-w-0 overflow-y-auto">
+44 -31
View File
@@ -124,7 +124,7 @@ export const Editor: React.FC = () => {
const latestFilesRef = useRef<any>(null);
const lastSyncedFilesRef = useRef<Record<string, any>>({});
const latestAppStateRef = useRef<any>(null);
const debouncedSaveRef = useRef<((elements: readonly any[], appState: any) => void) | null>(null);
const debouncedSaveRef = useRef<((drawingId: string, elements: readonly any[], appState: any) => void) | null>(null);
const emitFilesDeltaIfNeeded = useCallback(
(nextFiles: Record<string, any>) => {
@@ -361,13 +361,13 @@ export const Editor: React.FC = () => {
const didEmit = emitFilesDeltaIfNeeded(nextFiles);
// Persist after file data becomes available so new tabs (tab3) load correctly.
if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) {
debouncedSaveRef.current(latestElementsRef.current, latestAppStateRef.current);
if (didEmit && id && latestAppStateRef.current && debouncedSaveRef.current) {
debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current);
}
};
}
setIsReady(true);
}, [emitFilesDeltaIfNeeded]);
}, [emitFilesDeltaIfNeeded, id]);
// Handle #addLibrary URL hash parameter for importing libraries from links
useEffect(() => {
@@ -428,12 +428,12 @@ export const Editor: React.FC = () => {
scrollToContent: true,
}), []);
const saveDataRef = useRef<((elements: readonly any[], appState: any) => Promise<void>) | null>(null);
const savePreviewRef = useRef<((elements: readonly any[], appState: any, files: any) => Promise<void>) | null>(null);
const saveDataRef = useRef<((drawingId: string, elements: readonly any[], appState: any) => Promise<void>) | null>(null);
const savePreviewRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files: any) => Promise<void>) | null>(null);
const saveLibraryRef = useRef<((items: any[]) => Promise<void>) | null>(null);
saveDataRef.current = async (elements: readonly any[], appState: any) => {
if (!id) return;
saveDataRef.current = async (drawingId: string, elements: readonly any[], appState: any) => {
if (!drawingId) return;
try {
const persistableAppState = {
@@ -446,27 +446,27 @@ export const Editor: React.FC = () => {
const persistableElements = Array.isArray(snapshot) ? snapshot : [];
console.log("[Editor] Saving drawing", {
drawingId: id,
drawingId,
elementCount: persistableElements.length,
hasRenderableElements: persistableElements.some((el: any) => !el?.isDeleted),
appState: persistableAppState,
});
await api.updateDrawing(id, {
await api.updateDrawing(drawingId, {
elements: persistableElements,
appState: persistableAppState,
files: latestFilesRef.current || {},
});
console.log("[Editor] Save complete", { drawingId: id });
console.log("[Editor] Save complete", { drawingId });
} catch (err) {
console.error('Failed to save drawing', err);
toast.error("Failed to save changes");
}
};
savePreviewRef.current = async (elements: readonly any[], appState: any, files: any) => {
if (!id) return;
savePreviewRef.current = async (drawingId: string, elements: readonly any[], appState: any, files: any) => {
if (!drawingId) return;
try {
const currentSnapshot = latestElementsRef.current ?? elements;
@@ -484,13 +484,13 @@ export const Editor: React.FC = () => {
const preview = svg.outerHTML;
console.log("[Editor] Saving preview", {
drawingId: id,
drawingId,
elementCount: currentSnapshot.length,
});
await api.updateDrawing(id, { preview });
await api.updateDrawing(drawingId, { preview });
console.log("[Editor] Preview save complete", { drawingId: id });
console.log("[Editor] Preview save complete", { drawingId });
} catch (err) {
console.error('Failed to save preview', err);
}
@@ -509,9 +509,9 @@ export const Editor: React.FC = () => {
const debouncedSave = useCallback(
debounce((elements, appState) => {
debounce((drawingId, elements, appState) => {
if (saveDataRef.current) {
saveDataRef.current(elements, appState);
saveDataRef.current(drawingId, elements, appState);
}
}, 1000),
[] // Empty dependency array = Stable across renders
@@ -519,9 +519,9 @@ export const Editor: React.FC = () => {
// Allow non-hook code (e.g., Excalidraw API wrappers) to trigger debounced saves.
debouncedSaveRef.current = debouncedSave;
const debouncedSavePreview = useCallback(
debounce((elements, appState, files) => {
debounce((drawingId, elements, appState, files) => {
if (savePreviewRef.current) {
savePreviewRef.current(elements, appState, files);
savePreviewRef.current(drawingId, elements, appState, files);
}
}, 10000),
[]
@@ -536,6 +536,13 @@ export const Editor: React.FC = () => {
[]
);
useEffect(() => {
return () => {
debouncedSave.cancel();
debouncedSavePreview.cancel();
};
}, [debouncedSave, debouncedSavePreview]);
const broadcastChanges = useCallback(
throttle((elements: readonly any[], currentFiles?: Record<string, any>) => {
if (!socketRef.current || !id) return;
@@ -670,8 +677,9 @@ export const Editor: React.FC = () => {
const files = excalidrawAPI.current.getFiles() || {};
latestElementsRef.current = elements;
latestFilesRef.current = files;
await saveDataRef.current(elements, appState);
savePreviewRef.current(elements, appState, files);
if (!id) return;
await saveDataRef.current(id, elements, appState);
savePreviewRef.current(id, elements, appState, files);
toast.success("Saved changes to server");
}
}
@@ -739,7 +747,9 @@ export const Editor: React.FC = () => {
elementCount: allElements.length,
hasRenderableElements,
});
debouncedSave(allElements, appState);
if (id) {
debouncedSave(id, allElements, appState);
}
// Trigger Slow Preview Gen
const filesSnapshot = currentFiles;
@@ -748,8 +758,10 @@ export const Editor: React.FC = () => {
drawingId: id,
fileCount: Object.keys(filesSnapshot).length,
});
debouncedSavePreview(allElements, appState, filesSnapshot);
}, [debouncedSave, debouncedSavePreview, broadcastChanges]);
if (id) {
debouncedSavePreview(id, allElements, appState, filesSnapshot);
}
}, [debouncedSave, debouncedSavePreview, broadcastChanges, id]);
// Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously)
// are still broadcast to collaborators AND persisted to the server.
@@ -767,7 +779,7 @@ export const Editor: React.FC = () => {
// Persist after file data becomes available (covers the "tab 3" case).
if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) {
debouncedSaveRef.current(latestElementsRef.current, latestAppStateRef.current);
debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current);
}
}, 1000);
@@ -803,6 +815,7 @@ export const Editor: React.FC = () => {
// Save drawing and generate preview before navigating
try {
if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) {
if (!id) return;
const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
const appState = excalidrawAPI.current.getAppState();
const files = excalidrawAPI.current.getFiles() || {};
@@ -810,8 +823,8 @@ export const Editor: React.FC = () => {
latestFilesRef.current = files;
await Promise.all([
saveDataRef.current(elements, appState),
savePreviewRef.current(elements, appState, files)
saveDataRef.current(id, elements, appState),
savePreviewRef.current(id, elements, appState, files)
]);
console.log("[Editor] Saved on back navigation", { drawingId: id });
}
@@ -827,7 +840,7 @@ export const Editor: React.FC = () => {
<div className="h-screen flex flex-col bg-white dark:bg-neutral-950 overflow-hidden">
<header
className={clsx(
"h-14 bg-white dark:bg-neutral-900 border-b border-gray-200 dark:border-neutral-800 flex items-center px-4 justify-between z-10 fixed top-0 left-0 right-0 transition-transform duration-300",
"h-16 bg-white dark:bg-neutral-900 border-b border-gray-200 dark:border-neutral-800 flex items-center px-4 justify-between z-10 fixed top-0 left-0 right-0 transition-transform duration-300",
isHeaderVisible ? "translate-y-0" : "-translate-y-full"
)}
>
@@ -945,8 +958,8 @@ export const Editor: React.FC = () => {
<div
className="flex-1 w-full relative transition-all duration-300"
style={{
height: isHeaderVisible ? 'calc(100vh - 3.5rem)' : '100vh',
marginTop: isHeaderVisible ? '3.5rem' : '0'
height: isHeaderVisible ? 'calc(100vh - 4rem)' : '100vh',
marginTop: isHeaderVisible ? '4rem' : '0'
}}
>
{loadError ? (