+ Saved layouts appear in the layout list at the top of the
+ page. They stay in this browser only.
+
+
Paste a layout code or link. This replaces your current custom
diff --git a/client/src/components/Dashboard/Dashboard.tsx b/client/src/components/Dashboard/Dashboard.tsx
index 5777f3f32..bd96f5260 100644
--- a/client/src/components/Dashboard/Dashboard.tsx
+++ b/client/src/components/Dashboard/Dashboard.tsx
@@ -5,6 +5,8 @@ import LayoutPreset, { LayoutPresetType } from '@/enums/LayoutPreset';
import {
saveLayoutPreset,
getLayoutPreset,
+ getSavedLayouts,
+ loadSavedLayout,
receiveLayoutPreset,
} from '@/store/actions/settings';
import { RootState } from '@/store/reducers';
@@ -17,11 +19,20 @@ import SettingsModal from './SettingsModal';
import { startSocketWatcher } from '@/store/middleware/socketMiddleware';
import { readLayoutCodeFromUrl } from '@/components/ConfigurableLayout/layoutCode';
+// Saved layouts share the preset list, so their option values are prefixed.
+const SAVED_OPTION_PREFIX = 'saved:';
+
export default function Dashboard() {
const socket = useSelector((state: RootState) => state.socket);
const layoutPreset = useSelector(
(state: RootState) => state.settings.layoutPreset,
);
+ const savedLayouts = useSelector(
+ (state: RootState) => state.settings.savedLayouts,
+ );
+ const activeSavedLayout = useSelector(
+ (state: RootState) => state.settings.activeSavedLayout,
+ );
const enabled = useSelector((state: RootState) => state.status.enabled);
const batteryVoltage = useSelector(
(state: RootState) => state.status.batteryVoltage,
@@ -32,10 +43,26 @@ export default function Dashboard() {
useEffect(() => {
dispatch(getLayoutPreset());
+ dispatch(getSavedLayouts());
startSocketWatcher(dispatch);
}, [dispatch]);
+ // Saved layouts written by another tab show up here without a reload.
+ useEffect(() => {
+ const onStorage = (e: StorageEvent) => {
+ if (e.key === null || e.key === 'savedLayouts') {
+ dispatch(getSavedLayouts());
+ }
+ };
+
+ window.addEventListener('storage', onStorage);
+
+ return () => {
+ window.removeEventListener('storage', onStorage);
+ };
+ }, [dispatch]);
+
// Layout links are handled by the custom layout, so show it. The choice
// is only saved once the user applies the layout.
useEffect(() => {
@@ -62,11 +89,23 @@ export default function Dashboard() {
{socket.isConnected && (
({
type: GET_LAYOUT_PRESET,
});
+
+export const getSavedLayouts = (): GetSavedLayoutsAction => ({
+ type: GET_SAVED_LAYOUTS,
+});
+
+export const receiveSavedLayouts = (
+ layouts: SavedLayout[],
+ activeId: string | null,
+): ReceiveSavedLayoutsAction => ({
+ type: RECEIVE_SAVED_LAYOUTS,
+ layouts,
+ activeId,
+});
+
+export const saveLayout = (name: string, code: string): SaveLayoutAction => ({
+ type: SAVE_LAYOUT,
+ name,
+ code,
+});
+
+export const deleteSavedLayout = (id: string): DeleteSavedLayoutAction => ({
+ type: DELETE_SAVED_LAYOUT,
+ id,
+});
+
+export const loadSavedLayout = (
+ id: string,
+ force = false,
+): LoadSavedLayoutAction => ({
+ type: LOAD_SAVED_LAYOUT,
+ id,
+ force,
+});
+
+export const receiveLayoutToLoad = (
+ code: string,
+ nonce: number,
+): ReceiveLayoutToLoadAction => ({
+ type: RECEIVE_LAYOUT_TO_LOAD,
+ code,
+ nonce,
+});
+
+export const layoutLoaded = (): LayoutLoadedAction => ({
+ type: LAYOUT_LOADED,
+});
+
+export const setSavedLayoutEdited = (
+ edited: boolean,
+): SetSavedLayoutEditedAction => ({
+ type: SET_SAVED_LAYOUT_EDITED,
+ edited,
+});
diff --git a/client/src/store/middleware/storageMiddleware.ts b/client/src/store/middleware/storageMiddleware.ts
index a1a09d5ef..2e38420b7 100644
--- a/client/src/store/middleware/storageMiddleware.ts
+++ b/client/src/store/middleware/storageMiddleware.ts
@@ -1,11 +1,54 @@
import { Middleware } from 'redux';
+import { v4 as uuidv4 } from 'uuid';
import LayoutPreset, { LayoutPresetType } from '@/enums/LayoutPreset';
-import { GET_LAYOUT_PRESET, SAVE_LAYOUT_PRESET } from '@/store/types';
-import { receiveLayoutPreset } from '@/store/actions/settings';
+import {
+ SavedLayout,
+ DELETE_SAVED_LAYOUT,
+ GET_LAYOUT_PRESET,
+ GET_SAVED_LAYOUTS,
+ LOAD_SAVED_LAYOUT,
+ SAVE_LAYOUT,
+ SAVE_LAYOUT_PRESET,
+} from '@/store/types';
+import {
+ receiveLayoutPreset,
+ receiveLayoutToLoad,
+ receiveSavedLayouts,
+} from '@/store/actions/settings';
import { RootState } from '@/store/reducers';
const LAYOUT_PRESET_KEY = 'layoutPreset';
+const SAVED_LAYOUTS_KEY = 'savedLayouts';
+const ACTIVE_SAVED_LAYOUT_KEY = 'activeSavedLayoutId';
+
+const isSavedLayout = (value: unknown): value is SavedLayout =>
+ typeof value === 'object' &&
+ value !== null &&
+ typeof (value as SavedLayout).id === 'string' &&
+ typeof (value as SavedLayout).name === 'string' &&
+ typeof (value as SavedLayout).code === 'string';
+
+function readSavedLayouts(): SavedLayout[] {
+ try {
+ const parsed = JSON.parse(localStorage.getItem(SAVED_LAYOUTS_KEY) ?? '[]');
+ return Array.isArray(parsed) ? parsed.filter(isSavedLayout) : [];
+ } catch {
+ return [];
+ }
+}
+
+function writeSavedLayouts(layouts: SavedLayout[], activeId: string | null) {
+ localStorage.setItem(SAVED_LAYOUTS_KEY, JSON.stringify(layouts));
+ if (activeId === null) {
+ localStorage.removeItem(ACTIVE_SAVED_LAYOUT_KEY);
+ } else {
+ localStorage.setItem(ACTIVE_SAVED_LAYOUT_KEY, activeId);
+ }
+}
+
+const sameName = (a: string, b: string) =>
+ a.trim().toLowerCase() === b.trim().toLowerCase();
const storageMiddleware: Middleware, RootState> =
(store) => (next) => (action) => {
@@ -23,6 +66,88 @@ const storageMiddleware: Middleware, RootState> =
store.dispatch(receiveLayoutPreset(action.preset));
+ // Choosing the plain custom layout detaches it from a saved one.
+ if (
+ action.preset === LayoutPreset.CONFIGURABLE &&
+ store.getState().settings.activeSavedLayout !== null
+ ) {
+ const layouts = readSavedLayouts();
+ writeSavedLayouts(layouts, null);
+ store.dispatch(receiveSavedLayouts(layouts, null));
+ }
+
+ break;
+ }
+ case GET_SAVED_LAYOUTS: {
+ const layouts = readSavedLayouts();
+ const storedId = localStorage.getItem(ACTIVE_SAVED_LAYOUT_KEY);
+ const activeId = layouts.some((l) => l.id === storedId)
+ ? storedId
+ : null;
+
+ store.dispatch(receiveSavedLayouts(layouts, activeId));
+
+ break;
+ }
+ case SAVE_LAYOUT: {
+ const name = action.name.trim();
+ const layouts = readSavedLayouts();
+ const existing = layouts.find((l) => sameName(l.name, name));
+ const saved: SavedLayout = existing
+ ? { ...existing, name, code: action.code }
+ : { id: uuidv4(), name, code: action.code };
+ const updated = existing
+ ? layouts.map((l) => (l.id === saved.id ? saved : l))
+ : [...layouts, saved];
+
+ writeSavedLayouts(updated, saved.id);
+ store.dispatch(receiveSavedLayouts(updated, saved.id));
+
+ break;
+ }
+ case DELETE_SAVED_LAYOUT: {
+ const layouts = readSavedLayouts().filter((l) => l.id !== action.id);
+ const active = store.getState().settings.activeSavedLayout;
+ // Another tab may have removed the active layout in the meantime.
+ const activeId =
+ active !== null && layouts.some((l) => l.id === active.id)
+ ? active.id
+ : null;
+
+ writeSavedLayouts(layouts, activeId);
+ store.dispatch(receiveSavedLayouts(layouts, activeId));
+
+ break;
+ }
+ case LOAD_SAVED_LAYOUT: {
+ const layouts = readSavedLayouts();
+ const layout = layouts.find((l) => l.id === action.id);
+ const active = store.getState().settings.activeSavedLayout;
+
+ if (layout === undefined) {
+ // Gone from another tab: refresh the list instead of doing nothing.
+ const activeId =
+ active !== null && layouts.some((l) => l.id === active.id)
+ ? active.id
+ : null;
+ writeSavedLayouts(layouts, activeId);
+ store.dispatch(receiveSavedLayouts(layouts, activeId));
+ break;
+ }
+
+ // Coming back to the layout that is already loaded keeps any edits
+ // unless the caller asked for a reload. A queued load replaces any
+ // stale one. The preset is set directly: going through
+ // SAVE_LAYOUT_PRESET would detach the layout being loaded.
+ const alreadyActive = active !== null && active.id === layout.id;
+ localStorage.setItem(LAYOUT_PRESET_KEY, LayoutPreset.CONFIGURABLE);
+ store.dispatch(receiveLayoutPreset(LayoutPreset.CONFIGURABLE));
+ if (action.force || !alreadyActive) {
+ store.dispatch(receiveLayoutToLoad(layout.code, Date.now()));
+ }
+ writeSavedLayouts(layouts, layout.id);
+ store.dispatch(receiveSavedLayouts(layouts, layout.id));
+
break;
}
default:
diff --git a/client/src/store/reducers/settings.ts b/client/src/store/reducers/settings.ts
index ce43bab14..8d5474b69 100644
--- a/client/src/store/reducers/settings.ts
+++ b/client/src/store/reducers/settings.ts
@@ -1,7 +1,11 @@
import {
SettingState,
- ReceiveLayoutPresetAction,
+ SettingsAction,
+ LAYOUT_LOADED,
RECEIVE_LAYOUT_PRESET,
+ RECEIVE_LAYOUT_TO_LOAD,
+ RECEIVE_SAVED_LAYOUTS,
+ SET_SAVED_LAYOUT_EDITED,
} from '@/store/types';
const initialState: SettingState = {
@@ -9,11 +13,14 @@ const initialState: SettingState = {
// ReferenceError: can't access lexical declaration 'LayoutPreset' before initialization
// perhaps due to the root reducer type shenanigans?
layoutPreset: 'DEFAULT',
+ savedLayouts: [],
+ activeSavedLayout: null,
+ layoutToLoad: null,
};
const settingsReducer = (
state: SettingState = initialState,
- action: ReceiveLayoutPresetAction,
+ action: SettingsAction,
) => {
switch (action.type) {
case RECEIVE_LAYOUT_PRESET:
@@ -21,6 +28,51 @@ const settingsReducer = (
...state,
layoutPreset: action.preset,
};
+ case RECEIVE_SAVED_LAYOUTS: {
+ const active = state.activeSavedLayout;
+ return {
+ ...state,
+ savedLayouts: action.layouts,
+ activeSavedLayout:
+ action.activeId === null
+ ? null
+ : {
+ id: action.activeId,
+ edited:
+ active !== null && active.id === action.activeId
+ ? active.edited
+ : false,
+ },
+ // A load that nothing has consumed yet is dropped on detach, so it
+ // cannot surface later when the custom layout next mounts.
+ layoutToLoad: action.activeId === null ? null : state.layoutToLoad,
+ };
+ }
+ case RECEIVE_LAYOUT_TO_LOAD:
+ return {
+ ...state,
+ layoutToLoad: { code: action.code, nonce: action.nonce },
+ // The grid is about to match the saved code again.
+ activeSavedLayout:
+ state.activeSavedLayout === null
+ ? null
+ : { ...state.activeSavedLayout, edited: false },
+ };
+ case LAYOUT_LOADED:
+ return {
+ ...state,
+ layoutToLoad: null,
+ };
+ case SET_SAVED_LAYOUT_EDITED:
+ return state.activeSavedLayout === null
+ ? state
+ : {
+ ...state,
+ activeSavedLayout: {
+ ...state.activeSavedLayout,
+ edited: action.edited,
+ },
+ };
default:
return state;
}
diff --git a/client/src/store/types/index.ts b/client/src/store/types/index.ts
index 49a7d708e..1f8105ec1 100644
--- a/client/src/store/types/index.ts
+++ b/client/src/store/types/index.ts
@@ -39,12 +39,30 @@ export {
SAVE_LAYOUT_PRESET,
RECEIVE_LAYOUT_PRESET,
GET_LAYOUT_PRESET,
+ GET_SAVED_LAYOUTS,
+ RECEIVE_SAVED_LAYOUTS,
+ SAVE_LAYOUT,
+ DELETE_SAVED_LAYOUT,
+ LOAD_SAVED_LAYOUT,
+ RECEIVE_LAYOUT_TO_LOAD,
+ LAYOUT_LOADED,
+ SET_SAVED_LAYOUT_EDITED,
} from './settings';
export type {
SettingState,
+ SettingsAction,
+ SavedLayout,
SaveLayoutPresetAction,
ReceiveLayoutPresetAction,
GetLayoutPresetAction,
+ GetSavedLayoutsAction,
+ ReceiveSavedLayoutsAction,
+ SaveLayoutAction,
+ DeleteSavedLayoutAction,
+ LoadSavedLayoutAction,
+ ReceiveLayoutToLoadAction,
+ LayoutLoadedAction,
+ SetSavedLayoutEditedAction,
} from './settings';
export {
diff --git a/client/src/store/types/settings.ts b/client/src/store/types/settings.ts
index 2f71e71bf..2732ac389 100644
--- a/client/src/store/types/settings.ts
+++ b/client/src/store/types/settings.ts
@@ -6,8 +6,29 @@ export const SAVE_LAYOUT_PRESET = 'SAVE_LAYOUT_PRESET';
export const RECEIVE_LAYOUT_PRESET = 'RECEIVE_LAYOUT_PRESET';
export const GET_LAYOUT_PRESET = 'GET_LAYOUT_PRESET';
+export const GET_SAVED_LAYOUTS = 'GET_SAVED_LAYOUTS';
+export const RECEIVE_SAVED_LAYOUTS = 'RECEIVE_SAVED_LAYOUTS';
+export const SAVE_LAYOUT = 'SAVE_LAYOUT';
+export const DELETE_SAVED_LAYOUT = 'DELETE_SAVED_LAYOUT';
+export const LOAD_SAVED_LAYOUT = 'LOAD_SAVED_LAYOUT';
+export const RECEIVE_LAYOUT_TO_LOAD = 'RECEIVE_LAYOUT_TO_LOAD';
+export const LAYOUT_LOADED = 'LAYOUT_LOADED';
+export const SET_SAVED_LAYOUT_EDITED = 'SET_SAVED_LAYOUT_EDITED';
+
+// A custom layout kept in this browser, stored as its share code.
+export type SavedLayout = {
+ id: string;
+ name: string;
+ code: string;
+};
+
export type SettingState = {
layoutPreset: Values;
+ savedLayouts: SavedLayout[];
+ // The saved layout the custom layout was last loaded from, if any.
+ activeSavedLayout: { id: string; edited: boolean } | null;
+ // A layout waiting for the custom layout to apply it.
+ layoutToLoad: { code: string; nonce: number } | null;
};
export type SaveLayoutPresetAction = {
@@ -23,3 +44,53 @@ export type ReceiveLayoutPresetAction = {
export type GetLayoutPresetAction = {
type: typeof GET_LAYOUT_PRESET;
};
+
+export type GetSavedLayoutsAction = {
+ type: typeof GET_SAVED_LAYOUTS;
+};
+
+export type ReceiveSavedLayoutsAction = {
+ type: typeof RECEIVE_SAVED_LAYOUTS;
+ layouts: SavedLayout[];
+ activeId: string | null;
+};
+
+export type SaveLayoutAction = {
+ type: typeof SAVE_LAYOUT;
+ name: string;
+ code: string;
+};
+
+export type DeleteSavedLayoutAction = {
+ type: typeof DELETE_SAVED_LAYOUT;
+ id: string;
+};
+
+export type LoadSavedLayoutAction = {
+ type: typeof LOAD_SAVED_LAYOUT;
+ id: string;
+ // Reload even when this layout is already active, discarding edits.
+ force: boolean;
+};
+
+export type ReceiveLayoutToLoadAction = {
+ type: typeof RECEIVE_LAYOUT_TO_LOAD;
+ code: string;
+ nonce: number;
+};
+
+export type LayoutLoadedAction = {
+ type: typeof LAYOUT_LOADED;
+};
+
+export type SetSavedLayoutEditedAction = {
+ type: typeof SET_SAVED_LAYOUT_EDITED;
+ edited: boolean;
+};
+
+export type SettingsAction =
+ | ReceiveLayoutPresetAction
+ | ReceiveSavedLayoutsAction
+ | ReceiveLayoutToLoadAction
+ | LayoutLoadedAction
+ | SetSavedLayoutEditedAction;
diff --git a/docs/features.md b/docs/features.md
index 26a47e442..652e1cbd9 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -160,10 +160,12 @@ object RobotConstants {
The "Custom" layout preset lets you arrange any set of views on a grid. Press the pencil button in the bottom right to unlock the layout, then use the buttons around it to add views, delete views, clear the layout, or share it.
-To share a custom layout, choose "Share Layout" and copy the code or link. Another dashboard user can paste either one into the "Import a layout" box, or open the link directly. A link only works when the other user's dashboard has the same address (for example, both on Control Hubs at `192.168.43.1:8080/dash`), so send the code when in doubt.
+To share a custom layout, choose "Share or Save Layout" and copy the code or link. Another dashboard user can paste either one into the "Import a layout" box, or open the link directly. A link only works when the other user's dashboard has the same address (for example, both on Control Hubs at `192.168.43.1:8080/dash`), so send the code when in doubt.
A code carries only the position and size of each view, not settings inside a view such as graph colors. Sizes are fixed grid rows, so a layout made on a tall screen may run below the fold on a shorter one.
+To keep more than one custom layout, open "Share or Save Layout", give the layout a name and press Save. Saving under a name that is already taken replaces that entry. Saved layouts appear in the layout list at the top of the page; picking one replaces the current custom layout, the list marks it as edited once you change it, and picking "Custom" detaches from it. Load and Delete for each saved layout are in the same dialog. They are stored in this browser only.
+
Layout codes are plain text and can be edited by hand. Each entry is `:,,,` on a 12-column grid.
```
From 364ee5e3087c29c8285806e3703fd4f1186294b7 Mon Sep 17 00:00:00 2001
From: mxtmx <142958831+mxtmx@users.noreply.github.com>
Date: Mon, 28 Sep 2026 22:38:43 -0700
Subject: [PATCH 3/3] Harden layout codes and fix saved layouts across tabs and
reloads
---
.../ConfigurableLayout/ConfigurableLayout.tsx | 104 ++++++++++++++----
.../ConfigurableLayout/ShareLayoutModal.tsx | 15 ++-
.../ConfigurableLayout/layoutCode.ts | 102 +++++++++++------
client/src/components/Dashboard/Dashboard.tsx | 22 ++--
client/src/enums/ConfigurableView.ts | 1 +
client/src/store/actions/settings.ts | 9 +-
.../src/store/middleware/storageMiddleware.ts | 65 +++++------
client/src/store/reducers/settings.ts | 2 +-
client/src/store/types/settings.ts | 5 +-
docs/features.md | 2 +-
10 files changed, 221 insertions(+), 106 deletions(-)
diff --git a/client/src/components/ConfigurableLayout/ConfigurableLayout.tsx b/client/src/components/ConfigurableLayout/ConfigurableLayout.tsx
index ccc8d9490..406b9def1 100644
--- a/client/src/components/ConfigurableLayout/ConfigurableLayout.tsx
+++ b/client/src/components/ConfigurableLayout/ConfigurableLayout.tsx
@@ -36,13 +36,13 @@ import ViewPicker from './ViewPicker';
import ShareLayoutModal from './ShareLayoutModal';
import {
deleteSavedLayout,
+ getLayoutPreset,
layoutLoaded,
loadSavedLayout,
saveLayout,
saveLayoutPreset,
setSavedLayoutEdited,
} from '@/store/actions/settings';
-import { RootState } from '@/store/reducers';
import {
clearLayoutCodeFromUrl,
decodeLayout,
@@ -65,6 +65,8 @@ import { ReactComponent as ShareIcon } from '@/assets/icons/share.svg';
import { colors } from '@/hooks/useTheme';
import { useTheme } from '@/hooks/useTheme';
+import { RootState } from '@/store/reducers';
+import { ACTIVE_SAVED_LAYOUT_KEY } from '@/store/middleware/storageMiddleware';
function maxArray(a: number[], b: number[]) {
if (a.length !== b.length) {
@@ -343,6 +345,15 @@ export default function ConfigurableLayout() {
const gridWrapperRef = useRef(null);
const theme = useTheme();
+
+ const [isLayoutLocked, setIsLayoutLocked] = useState(true);
+ const [isGridReady, setIsGridReady] = useState(false);
+ const [isInDeleteMode, setIsInDeleteMode] = useState(false);
+ const [isShowingViewPicker, setIsShowingViewPicker] = useState(false);
+ const [isShareModalOpen, setIsShareModalOpen] = useState(false);
+ const [sharedImportText, setSharedImportText] = useState('');
+ const [loadError, setLoadError] = useState(null);
+
const dispatch = useDispatch();
const savedLayouts = useSelector(
(state: RootState) => state.settings.savedLayouts,
@@ -354,13 +365,6 @@ export default function ConfigurableLayout() {
(state: RootState) => state.settings.layoutToLoad,
);
- const [isLayoutLocked, setIsLayoutLocked] = useState(true);
- const [isGridReady, setIsGridReady] = useState(false);
- const [isInDeleteMode, setIsInDeleteMode] = useState(false);
- const [isShowingViewPicker, setIsShowingViewPicker] = useState(false);
- const [isShareModalOpen, setIsShareModalOpen] = useState(false);
- const [sharedImportText, setSharedImportText] = useState('');
-
const [gridBgSize, setGridBgSize] = useState(40);
const [
@@ -368,11 +372,19 @@ export default function ConfigurableLayout() {
{
initialize: initializeGrid,
append: setGrid,
- undo: undoGrid,
- redo: redoGrid,
+ undo: undoHistory,
+ redo: redoHistory,
},
] = useUndoHistory([]);
+ // Undo in the share dialog's text fields must not change the grid behind it.
+ const undoGrid = useCallback(() => {
+ if (!isShareModalOpen) undoHistory();
+ }, [isShareModalOpen, undoHistory]);
+ const redoGrid = useCallback(() => {
+ if (!isShareModalOpen) redoHistory();
+ }, [isShareModalOpen, redoHistory]);
+
const isFabIdle = useMouseIdleListener({
bottom: '0',
right: '0',
@@ -483,6 +495,31 @@ export default function ConfigurableLayout() {
);
}, [gridItems]);
+ const activeSavedLayoutId = activeSavedLayout?.id ?? null;
+
+ // A reload pairs the stored grid with the stored saved layout id, so both
+ // come from the tab that last changed either one.
+ useEffect(() => {
+ if (!isGridReady) return;
+ try {
+ window.localStorage.setItem(
+ LOCAL_STORAGE_LAYOUT_KEY,
+ JSON.stringify(gridItems),
+ );
+ if (activeSavedLayoutId === null) {
+ window.localStorage.removeItem(ACTIVE_SAVED_LAYOUT_KEY);
+ } else {
+ window.localStorage.setItem(
+ ACTIVE_SAVED_LAYOUT_KEY,
+ activeSavedLayoutId,
+ );
+ }
+ } catch {
+ // Storage is full. A grid with no saved layout id reloads as Custom.
+ window.localStorage.removeItem(ACTIVE_SAVED_LAYOUT_KEY);
+ }
+ }, [gridItems, activeSavedLayoutId, isGridReady]);
+
// A layout link opens the share modal with its code ready to apply. The
// hash stays until the user acts, so an early unmount does not lose it.
useEffect(() => {
@@ -560,6 +597,13 @@ export default function ConfigurableLayout() {
clearLayoutCodeFromUrl();
};
+ // A link shows the custom layout without saving that choice, so closing
+ // its dialog without applying anything goes back to the saved preset.
+ const dismissShareModal = () => {
+ closeShareModal();
+ if (sharedImportText !== '') dispatch(getLayoutPreset());
+ };
+
// Replaces the grid with a decoded code. Returns an error message, or null.
const applyLayoutCode = useCallback(
(text: string) => {
@@ -608,12 +652,14 @@ export default function ConfigurableLayout() {
if (layoutToLoad === null) return;
const error = applyLayoutCode(layoutToLoad.code);
dispatch(layoutLoaded());
- if (error !== null) {
- console.error(error);
- dispatch(saveLayoutPreset('CONFIGURABLE'));
- }
+ setLoadError(
+ error === null ? null : `Could not load "${layoutToLoad.name}". ${error}`,
+ );
+ if (error !== null) dispatch(saveLayoutPreset('CONFIGURABLE'));
}, [layoutToLoad, applyLayoutCode, dispatch]);
+ const shareCode = encodeLayout(toSharedItems(gridItems));
+
// The header shows which saved layout is loaded, and whether it has changed.
useEffect(() => {
if (!isGridReady || layoutToLoad !== null || activeSavedLayout === null) {
@@ -621,12 +667,12 @@ export default function ConfigurableLayout() {
}
const saved = savedLayouts.find((l) => l.id === activeSavedLayout.id);
if (saved === undefined) return;
- const edited = encodeLayout(toSharedItems(gridItems)) !== saved.code;
+ const edited = shareCode !== saved.code;
if (edited !== activeSavedLayout.edited) {
dispatch(setSavedLayoutEdited(edited));
}
}, [
- gridItems,
+ shareCode,
isGridReady,
layoutToLoad,
activeSavedLayout,
@@ -634,7 +680,6 @@ export default function ConfigurableLayout() {
dispatch,
]);
- const shareCode = encodeLayout(toSharedItems(gridItems));
const shareCheck = decodeLayout(shareCode, GRID_LIMITS);
const exportError =
gridItems.length === 0
@@ -695,6 +740,20 @@ export default function ConfigurableLayout() {
bgGridSize={gridBgSize}
isDarkMode={theme.isDarkMode}
>
+ {loadError !== null && (
+
+ {loadError}
+
+
+ )}
{gridItems.length === 0 && (
l.id === activeSavedLayout?.id)?.name ?? null
}
- onSave={(name) => dispatch(saveLayout(name, shareCode))}
+ onSave={(name) => {
+ try {
+ dispatch(saveLayout(name, shareCode));
+ return null;
+ } catch {
+ return 'Could not save. The browser storage may be full.';
+ }
+ }}
onLoad={(id) => {
dispatch(loadSavedLayout(id, true));
closeShareModal();
diff --git a/client/src/components/ConfigurableLayout/ShareLayoutModal.tsx b/client/src/components/ConfigurableLayout/ShareLayoutModal.tsx
index f1f2eb304..975faa67f 100644
--- a/client/src/components/ConfigurableLayout/ShareLayoutModal.tsx
+++ b/client/src/components/ConfigurableLayout/ShareLayoutModal.tsx
@@ -18,7 +18,8 @@ type ShareLayoutModalProps = {
savedLayouts: SavedLayout[];
// Name of the saved layout the current one came from, if any.
activeLayoutName: string | null;
- onSave: (name: string) => void;
+ // Returns an error message, or null when the layout was saved.
+ onSave: (name: string) => string | null;
onLoad: (id: string) => void;
onDelete: (id: string) => void;
};
@@ -107,6 +108,7 @@ export default function ShareLayoutModal({
const [copied, setCopied] = useState(null);
const [layoutName, setLayoutName] = useState('');
const [isSaved, setIsSaved] = useState(false);
+ const [saveError, setSaveError] = useState(null);
const [importText, setImportText] = useState('');
const [importError, setImportError] = useState(null);
const applyButtonRef = useRef(null);
@@ -121,6 +123,7 @@ export default function ShareLayoutModal({
setCopied(null);
setLayoutName(activeLayoutName ?? '');
setIsSaved(false);
+ setSaveError(null);
}
wasOpen.current = isOpen;
}, [isOpen, initialImportText, activeLayoutName]);
@@ -154,8 +157,9 @@ export default function ShareLayoutModal({
const save = () => {
if (!canSave) return;
- onSave(layoutName.trim());
- setIsSaved(true);
+ const error = onSave(layoutName.trim());
+ setSaveError(error);
+ setIsSaved(error === null);
};
const applyImport = () => {
@@ -282,6 +286,11 @@ export default function ShareLayoutModal({
{isSaved ? 'Saved' : ''}
+ {saveError !== null && (
+