feat: custom iframe widgets (close #656)

This commit is contained in:
David Ralph
2026-01-30 23:39:05 +00:00
parent f29b879215
commit b742c6a768
12 changed files with 839 additions and 12 deletions

View File

@@ -0,0 +1,58 @@
import { useState, useEffect } from 'react';
import EventBus from 'utils/eventbus';
import './customwidgets.scss';
const CenteredCustomWidgets = () => {
const [widgets, setWidgets] = useState([]);
useEffect(() => {
const loadWidgets = () => {
const stored = JSON.parse(localStorage.getItem('customWidgets') || '[]');
setWidgets(stored);
};
loadWidgets();
const handleRefresh = () => {
loadWidgets();
};
EventBus.on('refresh-widgets', handleRefresh);
window.addEventListener('storage', handleRefresh);
return () => {
EventBus.off('refresh-widgets', handleRefresh);
window.removeEventListener('storage', handleRefresh);
};
}, []);
// Only render center-positioned widgets
const centerWidgets = widgets.filter((w) => w.position === 'center');
if (centerWidgets.length === 0) {
return null;
}
return (
<>
{centerWidgets.map((widget) => (
<div
key={`${widget.key}-${widget.position}`}
id={widget.id || widget.key}
className={`custom-widget-centered${widget.renderAbove ? ' custom-widget-overlay' : ''}`}
title={widget.name}
>
<iframe
src={widget.url}
title={widget.name}
frameBorder="0"
allowFullScreen
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
/>
</div>
))}
</>
);
};
export { CenteredCustomWidgets as default, CenteredCustomWidgets };

View File

@@ -0,0 +1,58 @@
import { useState, useEffect } from 'react';
import EventBus from 'utils/eventbus';
import './customwidgets.scss';
const CustomWidgets = () => {
const [widgets, setWidgets] = useState([]);
useEffect(() => {
const loadWidgets = () => {
const stored = JSON.parse(localStorage.getItem('customWidgets') || '[]');
setWidgets(stored);
};
loadWidgets();
const handleRefresh = () => {
loadWidgets();
};
EventBus.on('refresh-widgets', handleRefresh);
window.addEventListener('storage', handleRefresh);
return () => {
EventBus.off('refresh-widgets', handleRefresh);
window.removeEventListener('storage', handleRefresh);
};
}, []);
// Only render corner-positioned widgets (not center)
const cornerWidgets = widgets.filter((w) => w.position !== 'center');
if (cornerWidgets.length === 0) {
return null;
}
return (
<>
{cornerWidgets.map((widget) => (
<div
key={`${widget.key}-${widget.position}`}
id={widget.id || widget.key}
className={`custom-widget-iframe custom-widget-${widget.position || 'top-right'}${widget.renderAbove ? ' custom-widget-overlay' : ''}`}
title={widget.name}
>
<iframe
src={widget.url}
title={widget.name}
frameBorder="0"
allowFullScreen
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
/>
</div>
))}
</>
);
};
export { CustomWidgets as default, CustomWidgets };

View File

@@ -0,0 +1,100 @@
import variables from 'config/variables';
import { useState, memo } from 'react';
import { MdWidgets, MdClose, MdCheck } from 'react-icons/md';
import { Tooltip } from 'components/Elements';
import { Button } from 'components/Elements';
import 'components/Form/Settings/Checkbox/Checkbox.scss';
function AddWidgetModal({ urlError, addWidget, closeModal, edit, editData, editWidget }) {
const [name, setName] = useState(edit ? editData.name : '');
const [url, setUrl] = useState(edit ? editData.url : '');
const [position, setPosition] = useState(edit ? editData.position : 'center');
const [renderAbove, setRenderAbove] = useState(edit ? (editData.renderAbove || false) : false);
return (
<div className="addLinkModal">
<div className="shareHeader">
<span className="title">
{edit
? variables.getMessage('modals.main.settings.sections.advanced.custom_widget_edit')
: variables.getMessage('modals.main.settings.sections.advanced.custom_widget_new')}
</span>
<Tooltip title={variables.getMessage('modals.main.settings.buttons.close')}>
<div className="close" onClick={() => closeModal()}>
<MdClose />
</div>
</Tooltip>
</div>
<div className="quicklinkModalTextbox">
<div className="text-field" style={{ gridColumn: 'span 2' }}>
<input
type="text"
className="text-field-input"
placeholder={variables.getMessage('modals.main.settings.sections.advanced.custom_widget_name')}
value={name}
onChange={(e) => setName(e.target.value.replace(/(\r\n|\n|\r)/gm, ''))}
/>
</div>
<div className="text-field" style={{ gridColumn: 'span 2' }}>
<input
type="text"
className="text-field-input"
placeholder={variables.getMessage('modals.main.settings.sections.advanced.custom_widget_url')}
value={url}
onChange={(e) => setUrl(e.target.value.replace(/(\r\n|\n|\r)/gm, ''))}
/>
</div>
<div className="text-field" style={{ gridColumn: 'span 2' }}>
<select
className="text-field-input"
value={position}
onChange={(e) => setPosition(e.target.value)}
>
<option value="center">Center (with other widgets)</option>
<option value="top-left">Top Left</option>
<option value="top-right">Top Right</option>
<option value="bottom-left">Bottom Left</option>
<option value="bottom-right">Bottom Right</option>
</select>
</div>
<div className="text-field" style={{ gridColumn: 'span 2' }}>
<div className="checkbox-wrapper">
<span className="checkbox-label">Render above other widgets</span>
<input
type="checkbox"
id="renderAbove"
checked={renderAbove}
onChange={(e) => setRenderAbove(e.target.checked)}
className="checkbox-input"
aria-label="Render above other widgets"
/>
<div className={`checkbox-box ${renderAbove ? 'checked' : ''}`}>
{renderAbove && <MdCheck />}
</div>
</div>
</div>
</div>
<div className="addFooter">
<span className="dropdown-error">{urlError}</span>
{edit ? (
<Button
type="settings"
onClick={() => editWidget(editData, name, url, position, renderAbove)}
icon={<MdWidgets />}
label={variables.getMessage('modals.main.settings.sections.advanced.custom_widget_edit_button')}
/>
) : (
<Button
type="settings"
onClick={() => addWidget(name, url, position, renderAbove)}
icon={<MdWidgets />}
label={variables.getMessage('modals.main.settings.sections.advanced.custom_widget_add')}
/>
)}
</div>
</div>
);
}
const MemoizedAddWidgetModal = memo(AddWidgetModal);
export { MemoizedAddWidgetModal as default, MemoizedAddWidgetModal as AddWidgetModal };

View File

@@ -0,0 +1,7 @@
import { MdOutlineDragIndicator } from 'react-icons/md';
export const DragHandle = () => (
<div className="widget-drag-handle" aria-hidden="true">
<MdOutlineDragIndicator />
</div>
);

View File

@@ -0,0 +1,64 @@
import { MdEdit, MdDelete, MdWidgets } from 'react-icons/md';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { DragHandle } from './DragHandle';
export const SortableWidgetItem = ({ value, startEditWidget, deleteWidget }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: value.key,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="widget-item"
role="listitem"
>
<DragHandle />
<div className="widget-icon">
<MdWidgets />
</div>
<div className="widget-content">
<div className="widget-name">{value.name} {value.id ? `(#${value.id})` : ''}</div>
<div className="widget-url">{value.url}</div>
<div className="widget-position">
{value.position ? value.position.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase()) : 'Top Right'}
{value.renderAbove ? ' • Above widgets' : ''}
</div>
</div>
<div className="widget-actions">
<button
className="widget-action-btn"
onClick={(e) => {
e.stopPropagation();
startEditWidget(value);
}}
title="Edit"
>
<MdEdit />
<span>Edit</span>
</button>
<button
className="widget-action-btn widget-remove-btn"
onClick={(e) => {
e.stopPropagation();
deleteWidget(value.key, e);
}}
title="Remove"
>
<MdDelete />
<span>Remove</span>
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,38 @@
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { SortableWidgetItem } from './SortableWidgetItem';
export const SortableWidgetList = ({ items, onDragEnd, startEditWidget, deleteWidget }) => {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={items.map((item) => item.key)} strategy={verticalListSortingStrategy}>
<div className="widgets-list" role="list">
{items.map((item) => (
<SortableWidgetItem
key={item.key}
value={item}
startEditWidget={startEditWidget}
deleteWidget={deleteWidget}
/>
))}
</div>
</SortableContext>
</DndContext>
);
};

View File

@@ -0,0 +1,4 @@
export { AddWidgetModal } from './AddWidgetModal';
export { SortableWidgetList } from './SortableWidgetList';
export { SortableWidgetItem } from './SortableWidgetItem';
export { DragHandle } from './DragHandle';

View File

@@ -0,0 +1,264 @@
@use 'scss/variables' as *;
// Iframe widgets positioned on the page
.custom-widget-iframe {
position: fixed;
z-index: -1;
width: 300px;
height: 400px;
background: rgba(0, 0, 0, 0.5);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
pointer-events: auto;
&.custom-widget-top-left {
top: 20px;
left: 20px;
}
&.custom-widget-top-right {
top: 20px;
right: 20px;
}
&.custom-widget-bottom-left {
bottom: 20px;
left: 20px;
}
&.custom-widget-bottom-right {
bottom: 20px;
right: 20px;
}
iframe {
width: 100%;
height: 100%;
border: none;
background: white;
}
}
// Centered widgets (rendered in main content area)
.custom-widget-centered {
width: 100%;
max-width: 800px;
height: 400px;
margin: 20px auto;
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
iframe {
width: 100%;
height: 100%;
border: none;
background: white;
}
}
// Overlay mode - renders above other widgets
.custom-widget-overlay {
z-index: 100 !important;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5);
border: 2px solid rgba(255, 255, 255, 0.15);
}
.widgets-container {
width: 100%;
padding: 0;
}
.widgets-list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0;
width: 100%;
}
.widget-item {
display: flex;
align-items: center;
gap: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 16px 20px;
transition: all 0.01s ease;
cursor: grab;
user-select: none;
&:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
.widget-drag-handle {
opacity: 1;
color: rgba(255, 255, 255, 0.7);
}
}
&:active {
cursor: grabbing;
}
.widget-drag-handle {
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
color: rgba(255, 255, 255, 0.4);
font-size: 24px;
padding: 4px;
opacity: 0.3;
transition:
opacity 0.2s ease,
color 0.2s ease;
&:active {
cursor: grabbing;
}
&:hover {
color: rgba(255, 255, 255, 0.9);
}
}
.widget-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
svg {
width: 28px;
height: 28px;
color: rgba(255, 255, 255, 0.7);
}
}
.widget-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
.widget-name {
font-size: 16px;
font-weight: 600;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.widget-url {
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.widget-position {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
font-style: italic;
line-height: 1.3;
}
}
.widget-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
.widget-action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
svg {
font-size: 16px;
flex-shrink: 0;
}
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.25);
}
&:active {
transform: translateY(0);
}
&.widget-remove-btn {
&:hover {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.4);
color: #ef4444;
}
}
}
}
}
.sortable-helper {
transform: scale(1.02);
z-index: 9999 !important;
opacity: 0.5 !important;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5) !important;
cursor: grabbing !important;
&.widget-item {
background: rgba(255, 255, 255, 0.1) !important;
border-color: rgba(255, 255, 255, 0.2) !important;
}
.widget-drag-handle {
opacity: 1 !important;
color: rgba(255, 255, 255, 0.9) !important;
}
.widget-icon,
.widget-content,
.widget-actions {
opacity: 1 !important;
visibility: visible !important;
}
* {
pointer-events: none !important;
}
}
.sortable-ghost {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
background: rgba(255, 255, 255, 0.1) !important;
transition: none !important;
}

View File

@@ -7,8 +7,12 @@ import {
MdRestartAlt as ResetIcon,
MdDataUsage,
MdError,
MdCode,
MdWidgets,
} from 'react-icons/md';
import '../customwidgets.scss';
import { exportSettings, importSettings } from 'utils/settings';
import { FileUpload, Text, Switch, Dropdown } from 'components/Form/Settings';
@@ -17,9 +21,20 @@ import { ResetModal, Button } from 'components/Elements';
import { Header, Section, Row, Content, Action } from 'components/Layout/Settings';
import time_zones from 'features/time/timezones.json';
import { AddWidgetModal, SortableWidgetList } from 'features/misc/components';
import { arrayMove } from '@dnd-kit/sortable';
import { isValidUrl } from 'utils/links';
import EventBus from 'utils/eventbus';
function AdvancedOptions({ currentSubSection, onSubSectionChange, sectionName }) {
const [resetModal, setResetModal] = useState(false);
const [widgets, setWidgets] = useState(
JSON.parse(localStorage.getItem('customWidgets') || '[]')
);
const [showAddWidgetModal, setShowAddWidgetModal] = useState(false);
const [widgetUrlError, setWidgetUrlError] = useState('');
const [editWidget, setEditWidget] = useState(false);
const [editWidgetData, setEditWidgetData] = useState('');
const ADVANCED_SECTION = 'modals.main.settings.sections.advanced';
const Data = () => {
@@ -75,7 +90,173 @@ function AdvancedOptions({ currentSubSection, onSubSectionChange, sectionName })
);
};
const CustomCSS = () => {
return (
<Row final={true}>
<Content
title={variables.getMessage('modals.main.settings.sections.advanced.custom_css')}
subtitle={variables.getMessage('modals.main.settings.sections.advanced.custom_css_subtitle')}
/>
<Action>
<Text name="customcss" textarea={true} category="other" customcss={true} />
</Action>
</Row>
);
};
const generateUniqueId = (name) => {
const baseName = (name || 'widget').toLowerCase().replace(/[^a-z0-9]/g, '-');
const existingIds = widgets.map((w) => w.id || '');
let id = baseName;
let counter = 1;
while (existingIds.includes(id)) {
id = `${baseName}-${counter}`;
counter++;
}
return id;
};
const deleteWidget = (key, event) => {
event.preventDefault();
const data = widgets.filter((i) => i.key !== key);
localStorage.setItem('customWidgets', JSON.stringify(data));
setWidgets(data);
EventBus.emit('refresh-widgets');
};
const addWidget = (name, url, position, renderAbove) => {
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
if (url.length <= 0 || isValidUrl(url) === false) {
setWidgetUrlError(variables.getMessage('widgets.quicklinks.url_error'));
return;
}
const widgetName = name || 'Widget';
const newItem = {
name: widgetName,
url,
position: position || 'center',
renderAbove: renderAbove || false,
id: generateUniqueId(widgetName),
key: Date.now().toString() + Math.random().toString(36).substring(2),
};
const data = [...widgets, newItem];
localStorage.setItem('customWidgets', JSON.stringify(data));
setWidgets(data);
setShowAddWidgetModal(false);
setWidgetUrlError('');
EventBus.emit('refresh-widgets');
};
const startEditWidget = (data) => {
setEditWidget(true);
setEditWidgetData(data);
setShowAddWidgetModal(true);
};
const updateWidget = (og, name, url, position, renderAbove) => {
const widgetName = name || 'Widget';
const data = widgets.map((item) => {
if (item.key === og.key) {
// Only regenerate ID if name changed
const newId = item.name !== widgetName ? generateUniqueId(widgetName) : (item.id || generateUniqueId(widgetName));
return {
...item,
name: widgetName,
url,
position: position || 'center',
renderAbove: renderAbove || false,
id: newId,
};
}
return item;
});
localStorage.setItem('customWidgets', JSON.stringify(data));
setWidgets(data);
setShowAddWidgetModal(false);
setEditWidget(false);
EventBus.emit('refresh-widgets');
};
const handleWidgetDragEnd = (event) => {
const { active, over } = event;
if (!over) return;
if (active.id === over.id) return;
const oldIndex = widgets.findIndex((item) => item.key === active.id);
const newIndex = widgets.findIndex((item) => item.key === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const newItems = arrayMove(widgets, oldIndex, newIndex);
setWidgets(newItems);
localStorage.setItem('customWidgets', JSON.stringify(newItems));
EventBus.emit('refresh-widgets');
};
const CustomWidget = () => {
return (
<>
<Row>
<Content
title={variables.getMessage('modals.main.settings.sections.advanced.custom_widget')}
subtitle={variables.getMessage('modals.main.settings.sections.advanced.custom_widget_subtitle')}
/>
<Action>
<Button
type="settings"
onClick={() => setShowAddWidgetModal(true)}
icon={<MdWidgets />}
label={variables.getMessage('modals.main.settings.sections.advanced.custom_widget_add')}
/>
</Action>
</Row>
{widgets.length === 0 ? (
<div className="photosEmpty">
<div className="emptyNewMessage">
<MdWidgets />
<span className="title">
{variables.getMessage('modals.main.settings.sections.advanced.custom_widget_no_widgets')}
</span>
<span className="subtitle">
{variables.getMessage('modals.main.settings.sections.advanced.custom_widget_add_some')}
</span>
<Button
type="settings"
onClick={() => setShowAddWidgetModal(true)}
icon={<MdWidgets />}
label={variables.getMessage('modals.main.settings.sections.advanced.custom_widget_add')}
/>
</div>
</div>
) : (
<div className="widgets-container">
<div className="messagesContainer">
<SortableWidgetList
items={widgets}
onDragEnd={handleWidgetDragEnd}
startEditWidget={(data) => startEditWidget(data)}
deleteWidget={(key, e) => deleteWidget(key, e)}
/>
</div>
</div>
)}
</>
);
};
const isDataSection = currentSubSection === 'data';
const isCustomCSSSection = currentSubSection === 'customcss';
const isCustomWidgetSection = currentSubSection === 'customwidget';
let header;
if (isDataSection) {
@@ -87,6 +268,24 @@ function AdvancedOptions({ currentSubSection, onSubSectionChange, sectionName })
report={false}
/>
);
} else if (isCustomCSSSection) {
header = (
<Header
title={variables.getMessage(`${ADVANCED_SECTION}.title`)}
secondaryTitle={variables.getMessage(`${ADVANCED_SECTION}.custom_css`)}
goBack={() => onSubSectionChange(null, sectionName)}
report={false}
/>
);
} else if (isCustomWidgetSection) {
header = (
<Header
title={variables.getMessage(`${ADVANCED_SECTION}.title`)}
secondaryTitle={variables.getMessage(`${ADVANCED_SECTION}.custom_widget`)}
goBack={() => onSubSectionChange(null, sectionName)}
report={false}
/>
);
} else {
header = <Header title={variables.getMessage(`${ADVANCED_SECTION}.title`)} report={false} />;
}
@@ -108,6 +307,36 @@ function AdvancedOptions({ currentSubSection, onSubSectionChange, sectionName })
<ResetModal modalClose={() => setResetModal(false)} />
</Modal>
</>
) : isCustomCSSSection ? (
<CustomCSS />
) : isCustomWidgetSection ? (
<>
<CustomWidget />
<Modal
closeTimeoutMS={100}
onRequestClose={() => {
setShowAddWidgetModal(false);
setWidgetUrlError('');
}}
isOpen={showAddWidgetModal}
className="Modal resetmodal mainModal"
overlayClassName="Overlay resetoverlay"
ariaHideApp={false}
>
<AddWidgetModal
urlError={widgetUrlError}
addWidget={(name, url) => addWidget(name, url)}
editWidget={(og, name, url) => updateWidget(og, name, url)}
edit={editWidget}
editData={editWidgetData}
closeModal={() => {
setShowAddWidgetModal(false);
setWidgetUrlError('');
setEditWidget(false);
}}
/>
</Modal>
</>
) : (
<>
<Section
@@ -116,6 +345,18 @@ function AdvancedOptions({ currentSubSection, onSubSectionChange, sectionName })
onClick={() => onSubSectionChange('data', sectionName)}
icon={<MdDataUsage />}
/>
<Section
title={variables.getMessage(`${ADVANCED_SECTION}.custom_css`)}
subtitle={variables.getMessage(`${ADVANCED_SECTION}.custom_css_subtitle`)}
onClick={() => onSubSectionChange('customcss', sectionName)}
icon={<MdCode />}
/>
<Section
title={variables.getMessage(`${ADVANCED_SECTION}.custom_widget`)}
subtitle={variables.getMessage(`${ADVANCED_SECTION}.custom_widget_subtitle`)}
onClick={() => onSubSectionChange('customwidget', sectionName)}
icon={<MdWidgets />}
/>
<Row>
<Content
title={variables.getMessage('modals.main.settings.sections.advanced.offline_mode')}
@@ -163,17 +404,6 @@ function AdvancedOptions({ currentSubSection, onSubSectionChange, sectionName })
<Text name="tabName" default={variables.getMessage('tabname')} category="other" />
</Action>
</Row>
<Row>
<Content
title={variables.getMessage('modals.main.settings.sections.advanced.custom_css')}
subtitle={variables.getMessage(
'modals.main.settings.sections.advanced.custom_css_subtitle',
)}
/>
<Action>
<Text name="customcss" textarea={true} category="other" customcss={true} />
</Action>
</Row>
<Row final={true}>
<Content
title={variables.getMessage('modals.main.settings.sections.experimental.title')}

View File

@@ -162,7 +162,7 @@ const LanguageOptions = () => {
>
<MdComputer style={{ fontSize: '18px', opacity: 0.8 }} />
{t('modals.main.settings.sections.language.use_system')}
<span style={{ opacity: 0.6 }}>({systemLanguage.name})</span>
<span style={{ opacity: 0.6 }}> {systemLanguage.name}</span>
</button>
)}
<div className="languageSettings">

View File

@@ -8,6 +8,7 @@ import QuickLinks from '../../quicklinks/QuickLinks';
import Date from '../../time/Date';
import Message from '../../message/Message';
import { WidgetsLayout } from 'components/Layout';
import CenteredCustomWidgets from '../CenteredCustomWidgets';
import EventBus from 'utils/eventbus';
@@ -77,6 +78,7 @@ const Widgets = () => {
<Fragment key={key}>{widgets[element]}</Fragment>
))}
{enabled('weatherEnabled') && online ? <Weather /> : null}
<CenteredCustomWidgets />
</Suspense>
</WidgetsLayout>
);