From 34a0a4e85f69379a9e294985f280912adee3f815 Mon Sep 17 00:00:00 2001 From: mxtmx <142958831+mxtmx@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:16:13 -0700 Subject: [PATCH 1/3] Add sharing of custom layouts by code or link Custom layouts can be exported as a short text code or a link, and imported by pasting either one or by opening the link. The code lists each view with its grid position, so it is stable across versions and editable by hand. --- client/src/assets/icons/share.svg | 4 + .../ConfigurableLayout/ConfigurableLayout.tsx | 130 +++++++++- .../ConfigurableLayout/ShareLayoutModal.tsx | 228 ++++++++++++++++++ .../ConfigurableLayout/layoutCode.ts | 170 +++++++++++++ client/src/components/Dashboard/Dashboard.tsx | 24 +- docs/features.md | 14 ++ docs/index.md | 2 + 7 files changed, 561 insertions(+), 11 deletions(-) create mode 100644 client/src/assets/icons/share.svg create mode 100644 client/src/components/ConfigurableLayout/ShareLayoutModal.tsx create mode 100644 client/src/components/ConfigurableLayout/layoutCode.ts diff --git a/client/src/assets/icons/share.svg b/client/src/assets/icons/share.svg new file mode 100644 index 000000000..c52ee66c6 --- /dev/null +++ b/client/src/assets/icons/share.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/src/components/ConfigurableLayout/ConfigurableLayout.tsx b/client/src/components/ConfigurableLayout/ConfigurableLayout.tsx index 68f9cbdbe..4f17f8d44 100644 --- a/client/src/components/ConfigurableLayout/ConfigurableLayout.tsx +++ b/client/src/components/ConfigurableLayout/ConfigurableLayout.tsx @@ -8,6 +8,7 @@ import { forwardRef, } from 'react'; import RGL, { WidthProvider, Layout } from 'react-grid-layout'; +import { useDispatch } from 'react-redux'; import { v4 as uuidv4 } from 'uuid'; import 'react-grid-layout/css/styles.css'; @@ -31,6 +32,15 @@ import LimelightView from '@/components/views/LimelightView'; import RadialFab from './RadialFab/RadialFab'; import RadialFabChild from './RadialFab/RadialFabChild'; import ViewPicker from './ViewPicker'; +import ShareLayoutModal from './ShareLayoutModal'; +import { saveLayoutPreset } from '@/store/actions/settings'; +import { + clearLayoutCodeFromUrl, + decodeLayout, + encodeLayout, + readLayoutCodeFromUrl, + SharedGridItem, +} from './layoutCode'; import useMouseIdleListener from '@/hooks/useMouseIdleListener'; import useUndoHistory from '@/hooks/useUndoHistory'; @@ -42,6 +52,7 @@ import { ReactComponent as LockIcon } from '@/assets/icons/lock.svg'; import { ReactComponent as RemoveCircleIcon } from '@/assets/icons/remove_circle.svg'; import { ReactComponent as RemoveCircleOutlineIcon } from '@/assets/icons/remove_circle_outline.svg'; import { ReactComponent as CreateIcon } from '@/assets/icons/create.svg'; +import { ReactComponent as ShareIcon } from '@/assets/icons/share.svg'; import { colors } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme'; @@ -88,6 +99,9 @@ const GRID_MARGIN = 10; const GRID_ITEM_MIN_WIDTH = 3; const GRID_DOT_PADDING = 10; +// Views that should only appear once in the layout +const SINGLETON_VIEWS = new Set([ConfigurableView.GAMEPAD_VIEW]); + const ReactGridLayout = WidthProvider(RGL); const Container = forwardRef< @@ -309,10 +323,13 @@ export default function ConfigurableLayout() { const gridWrapperRef = useRef(null); const theme = useTheme(); + const dispatch = useDispatch(); const [isLayoutLocked, setIsLayoutLocked] = useState(true); const [isInDeleteMode, setIsInDeleteMode] = useState(false); const [isShowingViewPicker, setIsShowingViewPicker] = useState(false); + const [isShareModalOpen, setIsShareModalOpen] = useState(false); + const [sharedImportText, setSharedImportText] = useState(''); const [gridBgSize, setGridBgSize] = useState(40); @@ -435,13 +452,28 @@ export default function ConfigurableLayout() { ); }, [gridItems]); - // Views that should only appear once in the layout - const singletonViews = new Set([ConfigurableView.GAMEPAD_VIEW]); + // 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(() => { + const importFromUrl = () => { + const code = readLayoutCodeFromUrl(); + if (code === null) return; + setSharedImportText(code); + setIsShareModalOpen(true); + }; + + importFromUrl(); + window.addEventListener('hashchange', importFromUrl); + + return () => { + window.removeEventListener('hashchange', importFromUrl); + }; + }, []); const existingViews = new Set(gridItems.map((e) => e.view)); const addItem = (item: ConfigurableView) => { - if (singletonViews.has(item) && existingViews.has(item)) return; + if (SINGLETON_VIEWS.has(item) && existingViews.has(item)) return; const ITEM_WIDTH = 4; const ITEM_HEIGHT = 4; @@ -492,6 +524,61 @@ export default function ConfigurableLayout() { setGrid(gridItems.filter((e) => e.id !== id)); }; + const closeShareModal = () => { + setIsShareModalOpen(false); + clearLayoutCodeFromUrl(); + }; + + const gridLimits = { cols: GRID_COL, minW: GRID_ITEM_MIN_WIDTH }; + + const importLayout = (text: string) => { + const result = decodeLayout(text, gridLimits); + if (!result.ok) return result.error; + + const seenSingletons = new Set(); + const newGrid: GridItem[] = []; + for (const item of result.items) { + if (SINGLETON_VIEWS.has(item.view)) { + if (seenSingletons.has(item.view)) continue; + seenSingletons.add(item.view); + } + newGrid.push({ + id: uuidv4(), + view: item.view, + layout: { + x: item.x, + y: item.y, + w: item.w, + h: item.h, + minW: GRID_ITEM_MIN_WIDTH, + isDraggable: !isLayoutLocked, + isResizable: !isLayoutLocked, + }, + }); + } + + setGrid(newGrid); + closeShareModal(); + dispatch(saveLayoutPreset('CONFIGURABLE')); + return null; + }; + + const sharedItems: SharedGridItem[] = gridItems.map((item) => ({ + view: item.view, + x: item.layout.x, + y: item.layout.y, + w: item.layout.w, + h: item.layout.h, + })); + const shareCode = encodeLayout(sharedItems); + const shareCheck = decodeLayout(shareCode, gridLimits); + const exportError = + gridItems.length === 0 + ? 'Add a view before sharing.' + : shareCheck.ok + ? null + : `This layout cannot be shared: ${shareCheck.error}`; + const clickFAB = () => { const toBeLocked = !isLayoutLocked; @@ -510,6 +597,7 @@ export default function ConfigurableLayout() { if (toBeLocked) { setIsShowingViewPicker(false); setIsInDeleteMode(false); + closeShareModal(); } }; @@ -627,8 +715,8 @@ export default function ConfigurableLayout() { > + { + setSharedImportText(''); + setIsShareModalOpen(true); + }} + > + + existingViews.has(v))) + new Set([...SINGLETON_VIEWS].filter((v) => existingViews.has(v))) } /> + ); } diff --git a/client/src/components/ConfigurableLayout/ShareLayoutModal.tsx b/client/src/components/ConfigurableLayout/ShareLayoutModal.tsx new file mode 100644 index 000000000..0542e951c --- /dev/null +++ b/client/src/components/ConfigurableLayout/ShareLayoutModal.tsx @@ -0,0 +1,228 @@ +import { Fragment, useEffect, useRef, useState } from 'react'; +import { Dialog, Transition } from '@headlessui/react'; +import clsx from 'clsx'; + +import { buildLayoutLink } from './layoutCode'; + +type ShareLayoutModalProps = { + isOpen: boolean; + onClose: () => void; + code: string; + // Set when the current layout cannot be shared, e.g. it is empty. + exportError: string | null; + initialImportText: string; + // Returns an error message, or null when the layout was applied. + onImport: (text: string) => string | null; +}; + +type CopyKind = 'code' | 'link'; +type CopyStatus = { kind: CopyKind; ok: boolean }; + +const BUTTON_CLASSES = clsx( + 'rounded px-3 py-1 text-sm transition-colors', + 'border border-gray-300 bg-gray-100 hover:border-gray-500', + 'dark:border-slate-500 dark:bg-slate-600 dark:hover:border-slate-300', + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500', + 'disabled:cursor-not-allowed disabled:opacity-50', +); + +const TEXTAREA_CLASSES = clsx( + 'w-full resize-none rounded border-gray-300 bg-gray-50 font-mono text-xs', + 'focus:border-primary-500 focus:ring-primary-500', + 'dark:border-slate-500 dark:bg-slate-800 dark:text-slate-100', +); + +const NOTE_CLASSES = 'mb-2 text-sm text-gray-600 dark:text-slate-300'; +const ERROR_CLASSES = 'text-sm text-red-600 dark:text-red-400'; + +async function copyToClipboard(text: string): Promise { + if (window.isSecureContext && navigator.clipboard) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (err) { + console.error(err); + } + } + + // The dashboard is normally served over plain HTTP from the robot, where + // the async clipboard API is unavailable. + const previouslyFocused = document.activeElement; + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.readOnly = true; + textArea.style.position = 'fixed'; + textArea.style.top = '0'; + textArea.style.left = '0'; + textArea.style.opacity = '0'; + document.body.append(textArea); + textArea.select(); + textArea.setSelectionRange(0, text.length); + let ok = false; + try { + ok = document.execCommand('copy'); + } catch (err) { + console.error(err); + } + document.body.removeChild(textArea); + if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus(); + return ok; +} + +export default function ShareLayoutModal({ + isOpen, + onClose, + code, + exportError, + initialImportText, + onImport, +}: ShareLayoutModalProps) { + const [copied, setCopied] = useState(null); + const [importText, setImportText] = useState(''); + const [importError, setImportError] = useState(null); + const applyButtonRef = useRef(null); + + useEffect(() => { + if (isOpen) { + setImportText(initialImportText); + setImportError(null); + setCopied(null); + } + }, [isOpen, initialImportText]); + + useEffect(() => { + if (copied === null || !copied.ok) return; + const timeout = window.setTimeout(() => setCopied(null), 1500); + return () => window.clearTimeout(timeout); + }, [copied]); + + const copy = (kind: CopyKind) => { + const text = kind === 'link' ? buildLayoutLink(code) : code; + copyToClipboard(text).then((ok) => setCopied({ kind, ok })); + }; + + const applyImport = () => { + setImportError(onImport(importText)); + }; + + return ( + + + +
+ + +
+
+ + + + Share Layout + + +

This layout

+

+ Send the code or link to another dashboard user. The link only + works when their dashboard has the same address. +

+ {exportError !== null ? ( +

+ {exportError} +

+ ) : ( +