feat: implement autosave, direct saving, and topbar UI indicators
This commit is contained in:
+69
-7
@@ -272,6 +272,20 @@ export default function Editor() {
|
|||||||
const [theme, setTheme] = useState<"light" | "dark">("dark");
|
const [theme, setTheme] = useState<"light" | "dark">("dark");
|
||||||
const [loadedModel, setLoadedModel] = useState<THREE.Object3D | null>(null);
|
const [loadedModel, setLoadedModel] = useState<THREE.Object3D | null>(null);
|
||||||
const [blobURLs, setBlobURLs] = useState<string[]>([]);
|
const [blobURLs, setBlobURLs] = useState<string[]>([]);
|
||||||
|
const [projectName, setProjectName] = useState<string>('');
|
||||||
|
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved' | 'offline'>('saved');
|
||||||
|
const [isOffline, setIsOffline] = useState(!navigator.onLine);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setIsOffline(false);
|
||||||
|
const handleOffline = () => setIsOffline(true);
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Cleanup blob URLs on unmount
|
// Cleanup blob URLs on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -314,7 +328,9 @@ export default function Editor() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.getProject(projectId);
|
const data = await api.getProject(projectId);
|
||||||
if (data.project && data.project.scene_data) {
|
if (data.project) {
|
||||||
|
if (data.project.name) setProjectName(data.project.name);
|
||||||
|
if (data.project.scene_data) {
|
||||||
let sd = data.project.scene_data;
|
let sd = data.project.scene_data;
|
||||||
|
|
||||||
if (sd.titansceneUrl) {
|
if (sd.titansceneUrl) {
|
||||||
@@ -680,7 +696,13 @@ export default function Editor() {
|
|||||||
if (sceneInputRef.current) sceneInputRef.current.value = '';
|
if (sceneInputRef.current) sceneInputRef.current.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveScene = async (name?: string) => {
|
const saveScene = async (name: string) => {
|
||||||
|
if (!name) {
|
||||||
|
alert("Please provide a valid file name.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaveStatus('saving');
|
||||||
|
|
||||||
const sceneData = {
|
const sceneData = {
|
||||||
sceneObjects, lightProps, groundProps, cameraProps,
|
sceneObjects, lightProps, groundProps, cameraProps,
|
||||||
envPreset, envRotation, backgroundColor, backgroundType,
|
envPreset, envRotation, backgroundColor, backgroundType,
|
||||||
@@ -688,7 +710,7 @@ export default function Editor() {
|
|||||||
modelData: modelData ? { ...modelData } : null
|
modelData: modelData ? { ...modelData } : null
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectName = name || exportDialogName || 'Untitled Scene';
|
const projectNameStr = name || exportDialogName || projectName || 'Untitled Scene';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsRendering(true);
|
setIsRendering(true);
|
||||||
@@ -728,25 +750,48 @@ export default function Editor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Upload to Cloud
|
// Upload to Cloud
|
||||||
const file = new File([blob], `${projectName.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.titanscene`, { type: 'application/octet-stream' });
|
const file = new File([blob], `${projectNameStr.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.titanscene`, { type: 'application/octet-stream' });
|
||||||
const fileUrl = await api.uploadAsset(file);
|
const fileUrl = await api.uploadAsset(file);
|
||||||
|
|
||||||
const result = await api.saveProject({
|
const result = await api.saveProject({
|
||||||
id: projectId && projectId !== 'new' ? projectId : undefined,
|
id: projectId && projectId !== 'new' ? projectId : undefined,
|
||||||
name: projectName,
|
name: projectNameStr,
|
||||||
scene_data: { titansceneUrl: fileUrl }
|
scene_data: { titansceneUrl: fileUrl }
|
||||||
});
|
});
|
||||||
if (projectId === 'new' && result.id) {
|
if (projectId === 'new' && result.id) {
|
||||||
navigate(`/editor/${result.id}`, { replace: true });
|
navigate(`/editor/${result.id}`, { replace: true });
|
||||||
}
|
}
|
||||||
|
setSaveStatus('saved');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save project to cloud", error);
|
console.error("Failed to save project to cloud", error);
|
||||||
|
setSaveStatus('unsaved');
|
||||||
} finally {
|
} finally {
|
||||||
setIsRendering(false);
|
setIsRendering(false);
|
||||||
}
|
}
|
||||||
setExportDialog(null);
|
setExportDialog(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveSceneRef = useRef(saveScene);
|
||||||
|
saveSceneRef.current = saveScene;
|
||||||
|
|
||||||
|
// Track changes to mark as unsaved
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveStatus !== 'unsaved' && saveStatus !== 'saving') {
|
||||||
|
setSaveStatus('unsaved');
|
||||||
|
}
|
||||||
|
}, [sceneObjects, lightProps, groundProps, cameraProps, envPreset, envRotation, backgroundColor, backgroundType, customHdri, backplateImage, additionalLights, modelBounds, cameraPresets, modelData]);
|
||||||
|
|
||||||
|
// Autosave loop
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveStatus !== 'unsaved' || isOffline || !projectId || projectId === 'new' || !projectName) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
saveSceneRef.current(projectName);
|
||||||
|
}, 120000); // 2 minutes
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [saveStatus, isOffline, projectId, projectName]);
|
||||||
|
|
||||||
const exportTitanScene = async (name: string) => {
|
const exportTitanScene = async (name: string) => {
|
||||||
const sceneData = {
|
const sceneData = {
|
||||||
sceneObjects, lightProps, groundProps, cameraProps,
|
sceneObjects, lightProps, groundProps, cameraProps,
|
||||||
@@ -802,7 +847,12 @@ export default function Editor() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openSaveDialog = () => {
|
const openSaveDialog = () => {
|
||||||
const defaultName = modelData?.name ? modelData.name.replace(/\.[^.]+$/, '') : 'scene';
|
if (projectId && projectId !== 'new' && projectName) {
|
||||||
|
saveScene(projectName);
|
||||||
|
setOpenMenu(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const defaultName = projectName || (modelData?.name ? modelData.name.replace(/\.[^.]+$/, '') : 'scene');
|
||||||
setExportDialogName(defaultName);
|
setExportDialogName(defaultName);
|
||||||
setExportDialog({ mode: 'scene', defaultName });
|
setExportDialog({ mode: 'scene', defaultName });
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
@@ -2078,7 +2128,19 @@ export default function Editor() {
|
|||||||
<span className="hover:text-white cursor-pointer">Help</span>
|
<span className="hover:text-white cursor-pointer">Help</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-4">
|
||||||
|
{isOffline ? (
|
||||||
|
<div className="flex items-center gap-1 text-red-500 font-medium">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
|
||||||
|
Offline
|
||||||
|
</div>
|
||||||
|
) : projectId && projectId !== 'new' ? (
|
||||||
|
<div className={`flex items-center gap-1 transition-colors ${saveStatus === 'saved' ? 'text-green-500' : saveStatus === 'saving' ? 'text-blue-400' : 'text-zinc-500'}`}>
|
||||||
|
{saveStatus === 'saving' && <Loader2 size={10} className="animate-spin" />}
|
||||||
|
{saveStatus === 'saved' && <Save size={10} />}
|
||||||
|
<span>{saveStatus === 'saved' ? 'Saved' : saveStatus === 'saving' ? 'Saving...' : 'Unsaved changes'}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="flex items-center gap-1 bg-white dark:bg-zinc-800 px-2 py-0.5 rounded">
|
<div className="flex items-center gap-1 bg-white dark:bg-zinc-800 px-2 py-0.5 rounded">
|
||||||
<span>Startup</span>
|
<span>Startup</span>
|
||||||
<ChevronDown size={12} />
|
<ChevronDown size={12} />
|
||||||
|
|||||||
Reference in New Issue
Block a user