mirror of
https://github.com/mue/mue.git
synced 2026-07-15 04:53:48 +02:00
feat: custom iframe widgets (close #656)
This commit is contained in:
58
src/features/misc/CenteredCustomWidgets.jsx
Normal file
58
src/features/misc/CenteredCustomWidgets.jsx
Normal 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 };
|
||||
58
src/features/misc/CustomWidgets.jsx
Normal file
58
src/features/misc/CustomWidgets.jsx
Normal 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 };
|
||||
100
src/features/misc/components/AddWidgetModal.jsx
Normal file
100
src/features/misc/components/AddWidgetModal.jsx
Normal 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 };
|
||||
7
src/features/misc/components/DragHandle.jsx
Normal file
7
src/features/misc/components/DragHandle.jsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { MdOutlineDragIndicator } from 'react-icons/md';
|
||||
|
||||
export const DragHandle = () => (
|
||||
<div className="widget-drag-handle" aria-hidden="true">
|
||||
<MdOutlineDragIndicator />
|
||||
</div>
|
||||
);
|
||||
64
src/features/misc/components/SortableWidgetItem.jsx
Normal file
64
src/features/misc/components/SortableWidgetItem.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
38
src/features/misc/components/SortableWidgetList.jsx
Normal file
38
src/features/misc/components/SortableWidgetList.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
4
src/features/misc/components/index.jsx
Normal file
4
src/features/misc/components/index.jsx
Normal file
@@ -0,0 +1,4 @@
|
||||
export { AddWidgetModal } from './AddWidgetModal';
|
||||
export { SortableWidgetList } from './SortableWidgetList';
|
||||
export { SortableWidgetItem } from './SortableWidgetItem';
|
||||
export { DragHandle } from './DragHandle';
|
||||
264
src/features/misc/customwidgets.scss
Normal file
264
src/features/misc/customwidgets.scss
Normal 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;
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user