feat(editor): the Save button is the auto-save indicator; drop the header pill

The header rendered a "Saved" / "Unsaved changes" text pill next to the
menus whenever a project was loaded. It ate width in the one row that has
to hold the whole editor toolbar, and it duplicated a signal the Save
button in the workspace panel can carry by colour alone.

The pill is gone. FileExplorer takes the AutoSaveState and colours its Save
button: green saved, orange unsaved changes, pulsing while saving, red on
failure; grey (unchanged) with no project loaded. The tooltip carries what
the pill used to say plus the last-save time. Nine locales get the four new
strings under editor.fileExplorer with English fallbacks.

AppHeader no longer takes autoSave; EditorPage passes it to FileExplorer
instead. No pro overlay code referenced the pill or the prop.
This commit is contained in:
David Montero 2026-08-18 02:26:35 +02:00
parent a0aeb101cd
commit 990cced209
13 changed files with 108 additions and 72 deletions

View File

@ -67,6 +67,27 @@
color: var(--color-action-primary);
}
/* The Save button doubles as the auto-save indicator once a project is
loaded (replaces the "Unsaved changes" text pill in the header). */
.file-explorer-save-btn.is-saved {
color: #3fb950;
}
.file-explorer-save-btn.is-dirty {
color: #f0883e;
}
.file-explorer-save-btn.is-saving {
color: #3fb950;
animation: velxio-pulse 1s ease-in-out infinite;
}
.file-explorer-save-btn.is-error {
color: #f85149;
}
.file-explorer-save-btn.is-saved:hover,
.file-explorer-save-btn.is-dirty:hover,
.file-explorer-save-btn.is-error:hover {
filter: brightness(1.25);
}
.file-explorer-list {
flex: 1;
overflow-y: auto;

View File

@ -1,6 +1,8 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore';
import type { AutoSaveState } from '../../hooks/useAutoSaveProject';
import type { WorkspaceFile } from '../../store/useEditorStore';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import {
@ -95,6 +97,26 @@ const IcoNewWorkspace = () => (
</svg>
);
/** Tooltip for the Save button: the plain action when no project is
* loaded, otherwise the auto-save status and the last save time. */
function saveButtonTitle(t: TFunction, autoSave?: AutoSaveState): string {
const base = t('editor.fileExplorer.saveProject');
if (!autoSave) return base;
const when = autoSave.lastSavedAt
? ` — ${t('editor.fileExplorer.lastSaved', 'last saved')} ${new Date(autoSave.lastSavedAt).toLocaleTimeString()}`
: '';
switch (autoSave.status) {
case 'dirty':
return `${base} — ${t('editor.fileExplorer.unsavedChanges')}${when}`;
case 'saving':
return `${base} — ${t('editor.fileExplorer.saving', 'saving…')}`;
case 'error':
return `${base} — ${t('editor.fileExplorer.saveFailed', 'save failed')}${autoSave.errorMessage ? `: ${autoSave.errorMessage}` : ''}`;
default:
return `${base} — ${t('editor.fileExplorer.saved', 'saved')}${when}`;
}
}
const IcoSave = () => (
<svg
width="22"
@ -346,9 +368,22 @@ interface ContextMenu {
interface FileExplorerProps {
onSaveClick: () => void;
onNewClick: () => void;
/** Auto-save state of the loaded project. When present, the Save button
* is the save indicator: green = saved, orange = unsaved changes,
* pulsing = saving, red = save failed. Replaces the text pill the header
* used to render ("Unsaved changes"), which ate toolbar width. */
autoSave?: AutoSaveState;
}
export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewClick }) => {
const SAVE_STATUS_CLASS: Record<AutoSaveState['status'], string> = {
idle: 'is-saved',
saved: 'is-saved',
dirty: 'is-dirty',
saving: 'is-saving',
error: 'is-error',
};
export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewClick, autoSave }) => {
const { t } = useTranslation();
// Hidden <input type="file"> we trigger via ref when the user clicks
// the Open project button. Accepts both .vlx (Velxio native) and .zip
@ -763,8 +798,9 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
style={{ display: 'none' }}
/>
<button
className="file-explorer-save-btn"
title={t('editor.fileExplorer.saveProject')}
className={`file-explorer-save-btn${autoSave ? ` ${SAVE_STATUS_CLASS[autoSave.status]}` : ''}`}
title={saveButtonTitle(t, autoSave)}
aria-label={saveButtonTitle(t, autoSave)}
onClick={onSaveClick}
>
<IcoSave />

View File

@ -7,15 +7,12 @@ import { LanguageSwitcher } from './LanguageSwitcher';
import { useLocalizedHref, useCurrentLocale } from '../../i18n/useLocalizedNavigate';
import { blogUrlFor } from '../../i18n/path';
import { trackVisitGitHub, trackVisitDiscord } from '../../utils/analytics';
import type { AutoSaveState } from '../../hooks/useAutoSaveProject';
import './LanguageSwitcher.css';
const GITHUB_URL = 'https://github.com/davidmonterocrespo24/velxio';
const DISCORD_URL = 'https://discord.gg/3mARjJrh4E';
interface AppHeaderProps {
/** Optional auto-save state — when set, renders a save status indicator. */
autoSave?: AutoSaveState;
/** Editor variant: a File/Edit menu bar rendered next to the logo. When
* set, the marketing nav links (Home / Docs / Pricing / …) are hidden —
* inside the editor they are noise that costs exactly the width the
@ -29,51 +26,7 @@ interface AppHeaderProps {
editorToolbar?: React.ReactNode;
}
const SAVE_STATUS_COPY: Record<AutoSaveState['status'], { label: string; color: string }> = {
idle: { label: 'Saved', color: '#7d8590' },
dirty: { label: 'Unsaved changes', color: '#f0883e' },
saving: { label: 'Saving…', color: '#3fb950' },
saved: { label: 'Saved', color: '#3fb950' },
error: { label: 'Save failed', color: '#f85149' },
};
const AutoSaveIndicator: React.FC<{ state: AutoSaveState }> = ({ state }) => {
const meta = SAVE_STATUS_COPY[state.status];
const tip =
state.status === 'error' && state.errorMessage
? `Auto-save failed: ${state.errorMessage}`
: state.lastSavedAt
? `Last saved ${new Date(state.lastSavedAt).toLocaleTimeString()}`
: 'Auto-save ready';
return (
<div
title={tip}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 10px',
fontSize: 12,
color: meta.color,
userSelect: 'none',
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: '50%',
background: meta.color,
opacity: state.status === 'saving' ? 0.7 : 1,
animation: state.status === 'saving' ? 'velxio-pulse 1s ease-in-out infinite' : 'none',
}}
/>
<span>{meta.label}</span>
</div>
);
};
export const AppHeader: React.FC<AppHeaderProps> = ({ autoSave, editorMenu, editorToolbar }) => {
export const AppHeader: React.FC<AppHeaderProps> = ({ editorMenu, editorToolbar }) => {
const location = useLocation();
const currentProject = useProjectStore((s) => s.currentProject);
const [menuOpen, setMenuOpen] = useState(false);
@ -224,12 +177,7 @@ export const AppHeader: React.FC<AppHeaderProps> = ({ autoSave, editorMenu, edit
</div>
{/* Editor toolbar strip — fills the middle the nav vacated. */}
{editorToolbar && (
<>
{autoSave && currentProject && <AutoSaveIndicator state={autoSave} />}
<div className="header-editor-toolbar">{editorToolbar}</div>
</>
)}
{editorToolbar && <div className="header-editor-toolbar">{editorToolbar}</div>}
{/* Right: language + share + auth + mobile hamburger. In the
desktop-editor variant this block does not render at all: the
@ -242,10 +190,6 @@ export const AppHeader: React.FC<AppHeaderProps> = ({ autoSave, editorMenu, edit
<div className="header-right">
<LanguageSwitcher />
{/* Auto-save status — only when a project is loaded and the editor
page mounted the hook */}
{autoSave && currentProject && <AutoSaveIndicator state={autoSave} />}
{/* Share button — visible when a project is loaded */}
{currentProject && location.pathname === '/editor' && (
<button

View File

@ -72,7 +72,11 @@
"contextMenu": {
"rename": "Umbenennen",
"delete": "Löschen"
}
},
"lastSaved": "zuletzt gespeichert",
"saving": "wird gespeichert…",
"saved": "gespeichert",
"saveFailed": "Speichern fehlgeschlagen"
},
"fileTabs": {
"unsavedChanges": "Ungespeicherte Änderungen",

View File

@ -74,7 +74,11 @@
"contextMenu": {
"rename": "Rename",
"delete": "Delete"
}
},
"lastSaved": "last saved",
"saving": "saving…",
"saved": "saved",
"saveFailed": "save failed"
},
"fileTabs": {
"unsavedChanges": "Unsaved changes",

View File

@ -74,7 +74,11 @@
"contextMenu": {
"rename": "Renombrar",
"delete": "Eliminar"
}
},
"lastSaved": "guardado por última vez",
"saving": "guardando…",
"saved": "guardado",
"saveFailed": "error al guardar"
},
"fileTabs": {
"unsavedChanges": "Cambios sin guardar",

View File

@ -72,7 +72,11 @@
"contextMenu": {
"rename": "Renommer",
"delete": "Supprimer"
}
},
"lastSaved": "dernier enregistrement",
"saving": "enregistrement…",
"saved": "enregistré",
"saveFailed": "échec de l'enregistrement"
},
"fileTabs": {
"unsavedChanges": "Modifications non enregistrées",

View File

@ -72,7 +72,11 @@
"contextMenu": {
"rename": "Rinomina",
"delete": "Elimina"
}
},
"lastSaved": "ultimo salvataggio",
"saving": "salvataggio…",
"saved": "salvato",
"saveFailed": "salvataggio non riuscito"
},
"fileTabs": {
"unsavedChanges": "Modifiche non salvate",

View File

@ -72,7 +72,11 @@
"contextMenu": {
"rename": "名前変更",
"delete": "削除"
}
},
"lastSaved": "最終保存",
"saving": "保存中…",
"saved": "保存済み",
"saveFailed": "保存に失敗しました"
},
"fileTabs": {
"unsavedChanges": "未保存の変更",

View File

@ -72,7 +72,11 @@
"contextMenu": {
"rename": "Renomear",
"delete": "Excluir"
}
},
"lastSaved": "salvo pela última vez",
"saving": "salvando…",
"saved": "salvo",
"saveFailed": "falha ao salvar"
},
"fileTabs": {
"unsavedChanges": "Alterações não salvas",

View File

@ -72,7 +72,11 @@
"contextMenu": {
"rename": "Переименовать",
"delete": "Удалить"
}
},
"lastSaved": "последнее сохранение",
"saving": "сохранение…",
"saved": "сохранено",
"saveFailed": "ошибка сохранения"
},
"fileTabs": {
"unsavedChanges": "Несохранённые изменения",

View File

@ -72,7 +72,11 @@
"contextMenu": {
"rename": "重命名",
"delete": "删除"
}
},
"lastSaved": "上次保存",
"saving": "保存中…",
"saved": "已保存",
"saveFailed": "保存失败"
},
"fileTabs": {
"unsavedChanges": "未保存的更改",

View File

@ -544,7 +544,6 @@ export const EditorPage: React.FC = () => {
return (
<div className="app">
<AppHeader
autoSave={autoSave}
editorMenu={!isMobile ? <EditorMenuBar /> : undefined}
editorToolbar={unifiedToolbar}
/>
@ -628,7 +627,7 @@ export const EditorPage: React.FC = () => {
}}
>
<div style={{ flex: 1, minHeight: 0, display: 'flex', overflow: 'hidden' }}>
<FileExplorer onSaveClick={handleSaveClick} onNewClick={handleNewClick} />
<FileExplorer onSaveClick={handleSaveClick} onNewClick={handleNewClick} autoSave={autoSave} />
</div>
{accountBlock && (
<div className="explorer-account-footer">{accountBlock}</div>