refactor: split quicklinks, move features to functional components

This commit is contained in:
David Ralph
2025-10-28 23:04:19 +00:00
parent 2eed0f7307
commit 293cc93500
39 changed files with 2847 additions and 3021 deletions

View File

@@ -1,4 +1,4 @@
import { PureComponent, createRef } from 'react';
import { memo, useRef, useState, useEffect, useCallback } from 'react';
import { Tooltip } from 'components/Elements';
import EventBus from 'utils/eventbus';
@@ -18,18 +18,13 @@ const readQuicklinks = () => {
}
};
class QuickLinks extends PureComponent {
constructor() {
super();
this.state = {
items: readQuicklinks(),
forceUpdate: 0, // Used to force complete re-render
};
this.quicklinksContainer = createRef();
}
const QuickLinks = memo(() => {
const [items, setItems] = useState(readQuicklinks());
const [forceUpdate, setForceUpdate] = useState(0); // Used to force complete re-render
const quicklinksContainer = useRef(null);
// widget zoom
setZoom(element) {
const setZoom = useCallback((element) => {
if (!element) return;
const zoom = localStorage.getItem('zoomQuicklinks') || 100;
@@ -42,103 +37,109 @@ class QuickLinks extends PureComponent {
img.style.height = `${30 * Number(zoom / 100)}px`;
}
}
}
}, []);
componentDidMount() {
EventBus.on('refresh', (data) => {
useEffect(() => {
const handleRefresh = (data) => {
if (data === 'quicklinks') {
if (localStorage.getItem('quicklinksenabled') === 'false') {
return (this.quicklinksContainer.current.style.display = 'none');
if (quicklinksContainer.current) {
quicklinksContainer.current.style.display = 'none';
}
return;
}
this.quicklinksContainer.current.style.display = 'flex';
if (quicklinksContainer.current) {
quicklinksContainer.current.style.display = 'flex';
}
this.setState({
items: readQuicklinks(),
forceUpdate: Date.now(),
}, () => {
this.setZoom(this.quicklinksContainer.current);
});
setItems(readQuicklinks());
setForceUpdate(Date.now());
}
});
this.setZoom(this.quicklinksContainer.current);
}
componentWillUnmount() {
EventBus.off('refresh');
}
render() {
let target,
rel = null;
if (localStorage.getItem('quicklinksnewtab') === 'true') {
target = '_blank';
rel = 'noopener noreferrer';
}
const tooltipEnabled = localStorage.getItem('quicklinkstooltip');
const quickLink = (item, index) => {
if (localStorage.getItem('quickLinksStyle') === 'text') {
return (
<a
className="quicklinkstext"
key={`quicklink-${item.key}-${index}`}
href={item.url}
target={target}
rel={rel}
draggable={false}
>
{item.name}
</a>
);
}
const img =
item.icon ||
'https://icon.horse/icon/' + item.url.replace('https://', '').replace('http://', '');
if (localStorage.getItem('quickLinksStyle') === 'metro') {
return (
<a
className="quickLinksMetro"
key={`quicklink-${item.key}-${index}`}
href={item.url}
target={target}
rel={rel}
draggable={false}
>
<img src={img} alt={item.name} draggable={false} />
<span className="subtitle">{item.name}</span>
</a>
);
}
const link = (
<a key={`quicklink-${item.key}-${index}`} href={item.url} target={target} rel={rel} draggable={false}>
<img src={img} alt={item.name} draggable={false} />
</a>
);
return tooltipEnabled === 'true' ? (
<Tooltip title={item.name} placement="bottom" key={`quicklink-${item.key}-${index}`}>
{link}
</Tooltip>
) : (
link
);
};
return (
<div
className="quicklinkscontainer"
ref={this.quicklinksContainer}
>
{this.state.items && this.state.items.map((item, index) => quickLink(item, index))}
</div>
);
EventBus.on('refresh', handleRefresh);
setZoom(quicklinksContainer.current);
return () => {
EventBus.off('refresh', handleRefresh);
};
}, [setZoom]);
useEffect(() => {
setZoom(quicklinksContainer.current);
}, [items, forceUpdate, setZoom]);
let target, rel = null;
if (localStorage.getItem('quicklinksnewtab') === 'true') {
target = '_blank';
rel = 'noopener noreferrer';
}
}
const tooltipEnabled = localStorage.getItem('quicklinkstooltip');
const quickLink = (item, index) => {
if (localStorage.getItem('quickLinksStyle') === 'text') {
return (
<a
className="quicklinkstext"
key={`quicklink-${item.key}-${index}`}
href={item.url}
target={target}
rel={rel}
draggable={false}
>
{item.name}
</a>
);
}
const img =
item.icon ||
'https://icon.horse/icon/' + item.url.replace('https://', '').replace('http://', '');
if (localStorage.getItem('quickLinksStyle') === 'metro') {
return (
<a
className="quickLinksMetro"
key={`quicklink-${item.key}-${index}`}
href={item.url}
target={target}
rel={rel}
draggable={false}
>
<img src={img} alt={item.name} draggable={false} />
<span className="subtitle">{item.name}</span>
</a>
);
}
const link = (
<a key={`quicklink-${item.key}-${index}`} href={item.url} target={target} rel={rel} draggable={false}>
<img src={img} alt={item.name} draggable={false} />
</a>
);
return tooltipEnabled === 'true' ? (
<Tooltip title={item.name} placement="bottom" key={`quicklink-${item.key}-${index}`}>
{link}
</Tooltip>
) : (
link
);
};
return (
<div
className="quicklinkscontainer"
ref={quicklinksContainer}
>
{items && items.map((item, index) => quickLink(item, index))}
</div>
);
});
QuickLinks.displayName = 'QuickLinks';
export { QuickLinks as default, QuickLinks };

View File

@@ -1,190 +1,58 @@
import variables from 'config/variables';
import { PureComponent, createRef } from 'react';
import { MdAddLink, MdLinkOff, MdOutlineDragIndicator, MdEdit, MdDelete } from 'react-icons/md';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useState, useEffect, useRef } from 'react';
import { MdAddLink, MdLinkOff } from 'react-icons/md';
import { arrayMove } from '@dnd-kit/sortable';
import { Header, Row, Content, Action, PreferencesWrapper } from 'components/Layout/Settings';
import { Checkbox, Dropdown } from 'components/Form/Settings';
import { Button } from 'components/Elements';
import Modal from 'react-modal';
import { AddModal } from 'components/Elements/AddModal';
import { SortableList } from './components';
import { readQuicklinks } from './utils/quicklinksUtils';
import EventBus from 'utils/eventbus';
import { getTitleFromUrl, isValidUrl } from 'utils/links';
const readQuicklinks = () => {
try {
const raw = localStorage.getItem('quicklinks');
if (!raw) return [];
const data = JSON.parse(raw);
return Array.isArray(data) ? data : [];
} catch (e) {
console.warn('Failed to parse quicklinks from localStorage. Resetting to []', e);
return [];
}
};
const QuickLinksOptions = () => {
const [items, setItems] = useState(readQuicklinks());
const [showAddModal, setShowAddModal] = useState(false);
const [urlError, setUrlError] = useState('');
const [iconError, setIconError] = useState('');
const [edit, setEdit] = useState(false);
const [editData, setEditData] = useState('');
const [enabled, setEnabled] = useState(localStorage.getItem('quicklinksenabled') !== 'false');
const DragHandle = () => (
<div className="quicklink-drag-handle" aria-hidden="true">
<MdOutlineDragIndicator />
</div>
);
const quicklinksContainer = useRef();
const silenceEventRef = useRef(false);
const SortableItem = ({ value, enabled, startEditLink, deleteLink }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: value.key,
disabled: !enabled,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const getIconUrl = (item) => {
return (
item.icon ||
'https://icon.horse/icon/' + item.url.replace('https://', '').replace('http://', '')
);
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={`quicklink-item ${!enabled ? 'disabled' : ''}`}
role="listitem"
>
<DragHandle />
<div className="quicklink-icon">
<img src={getIconUrl(value)} alt={value.name} draggable={false} />
</div>
<div className="quicklink-content">
<div className="quicklink-name">{value.name}</div>
<div className="quicklink-url">{value.url}</div>
</div>
<div className="quicklink-actions">
<button
className="quicklink-action-btn"
onClick={(e) => {
if (!enabled) return;
e.stopPropagation();
startEditLink(value);
}}
title="Edit"
disabled={!enabled}
aria-disabled={!enabled}
>
<MdEdit />
<span>Edit</span>
</button>
<button
className="quicklink-action-btn quicklink-remove-btn"
onClick={(e) => {
if (!enabled) return;
e.stopPropagation();
deleteLink(value.key, e);
}}
title="Remove"
disabled={!enabled}
aria-disabled={!enabled}
>
<MdDelete />
<span>Remove</span>
</button>
</div>
</div>
);
};
const SortableList = ({ items, enabled, onDragEnd, startEditLink, deleteLink }) => {
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="quicklinks-list" role="list">
{items.map((item) => (
<SortableItem
key={item.key}
value={item}
enabled={enabled}
startEditLink={startEditLink}
deleteLink={deleteLink}
/>
))}
</div>
</SortableContext>
</DndContext>
);
};
class QuickLinksOptions extends PureComponent {
constructor() {
super();
this.state = {
items: readQuicklinks(),
showAddModal: false,
urlError: '',
iconError: '',
edit: false,
editData: '',
enabled: localStorage.getItem('quicklinksenabled') !== 'false',
};
this.quicklinksContainer = createRef();
this.silenceEvent = false;
this.handleRefresh = null;
}
setContainerDisplay(enabled) {
if (!this.quicklinksContainer || !this.quicklinksContainer.current) return;
const el = this.quicklinksContainer.current;
const setContainerDisplay = (enabled) => {
if (!quicklinksContainer || !quicklinksContainer.current) return;
const el = quicklinksContainer.current;
el.classList.toggle('disabled', !enabled);
if (!enabled) {
el.setAttribute('aria-hidden', 'true');
} else {
el.removeAttribute('aria-hidden');
}
}
deleteLink(key, event) {
};
const deleteLink = (key, event) => {
event.preventDefault();
const stored = readQuicklinks();
const data = stored.filter((i) => i.key !== key);
this.silenceEvent = true;
silenceEventRef.current = true;
localStorage.setItem('quicklinks', JSON.stringify(data));
this.setState({ items: data }, () => {
variables.stats.postEvent('feature', 'Quicklink delete');
EventBus.emit('refresh', 'quicklinks');
setTimeout(() => {
this.silenceEvent = false;
}, 0);
});
}
setItems(data);
variables.stats.postEvent('feature', 'Quicklink delete');
EventBus.emit('refresh', 'quicklinks');
setTimeout(() => {
silenceEventRef.current = false;
}, 0);
};
async addLink(name, url, icon) {
const addLink = async (name, url, icon) => {
const data = readQuicklinks();
if (!url.startsWith('http://') && !url.startsWith('https://')) {
@@ -192,11 +60,13 @@ class QuickLinksOptions extends PureComponent {
}
if (url.length <= 0 || isValidUrl(url) === false) {
return this.setState({ urlError: variables.getMessage('widgets.quicklinks.url_error') });
setUrlError(variables.getMessage('widgets.quicklinks.url_error'));
return;
}
if (icon.length > 0 && isValidUrl(icon) === false) {
return this.setState({ iconError: variables.getMessage('widgets.quicklinks.url_error') });
setIconError(variables.getMessage('widgets.quicklinks.url_error'));
return;
}
const newItem = {
@@ -208,22 +78,26 @@ class QuickLinksOptions extends PureComponent {
data.push(newItem);
this.silenceEvent = true;
silenceEventRef.current = true;
localStorage.setItem('quicklinks', JSON.stringify(data));
this.setState({ items: data, showAddModal: false, urlError: '', iconError: '' }, () => {
variables.stats.postEvent('feature', 'Quicklink add');
EventBus.emit('refresh', 'quicklinks');
setTimeout(() => {
this.silenceEvent = false;
}, 0);
});
}
setItems(data);
setShowAddModal(false);
setUrlError('');
setIconError('');
variables.stats.postEvent('feature', 'Quicklink add');
EventBus.emit('refresh', 'quicklinks');
setTimeout(() => {
silenceEventRef.current = false;
}, 0);
};
startEditLink(data) {
this.setState({ edit: true, editData: data, showAddModal: true });
}
const startEditLink = (data) => {
setEdit(true);
setEditData(data);
setShowAddModal(true);
};
async editLink(og, name, url, icon) {
const editLink = async (og, name, url, icon) => {
const data = readQuicklinks();
const dataobj = data.find((i) => i.key === og.key);
if (!dataobj) return;
@@ -232,233 +106,220 @@ class QuickLinksOptions extends PureComponent {
dataobj.url = url;
dataobj.icon = icon || '';
this.silenceEvent = true;
silenceEventRef.current = true;
localStorage.setItem('quicklinks', JSON.stringify(data));
this.setState({ items: data, showAddModal: false, edit: false }, () => {
EventBus.emit('refresh', 'quicklinks');
setTimeout(() => {
this.silenceEvent = false;
}, 0);
});
}
arrayMove = (array, oldIndex, newIndex) => {
const result = Array.from(array);
const [removed] = result.splice(oldIndex, 1);
result.splice(newIndex, 0, removed);
return result;
setItems(data);
setShowAddModal(false);
setEdit(false);
EventBus.emit('refresh', 'quicklinks');
setTimeout(() => {
silenceEventRef.current = false;
}, 0);
};
handleDragEnd = (event) => {
const handleDragEnd = (event) => {
const { active, over } = event;
if (!over || !this.state.enabled) return;
if (!over || !enabled) return;
if (active.id === over.id) return;
const oldIndex = this.state.items.findIndex((item) => item.key === active.id);
const newIndex = this.state.items.findIndex((item) => item.key === over.id);
const oldIndex = items.findIndex((item) => item.key === active.id);
const newIndex = items.findIndex((item) => item.key === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const newItems = arrayMove(this.state.items, oldIndex, newIndex);
const newItems = arrayMove(items, oldIndex, newIndex);
this.silenceEvent = true;
this.setState({ items: newItems }, () => {
localStorage.setItem('quicklinks', JSON.stringify(newItems));
EventBus.emit('refresh', 'quicklinks');
setTimeout(() => {
this.silenceEvent = false;
}, 0);
});
silenceEventRef.current = true;
setItems(newItems);
localStorage.setItem('quicklinks', JSON.stringify(newItems));
EventBus.emit('refresh', 'quicklinks');
setTimeout(() => {
silenceEventRef.current = false;
}, 0);
};
componentDidMount() {
this.setContainerDisplay(this.state.enabled);
useEffect(() => {
setContainerDisplay(enabled);
}, [enabled]);
this.handleRefresh = (data) => {
useEffect(() => {
setContainerDisplay(enabled);
const handleRefresh = (data) => {
if (data !== 'quicklinks') return;
if (this.silenceEvent) return;
if (silenceEventRef.current) return;
const enabled = localStorage.getItem('quicklinksenabled') !== 'false';
const newEnabled = localStorage.getItem('quicklinksenabled') !== 'false';
const newItems = readQuicklinks();
const oldItems = this.state.items || [];
const oldKeys = new Set(oldItems.map((i) => i.key));
const newKeys = new Set(newItems.map((i) => i.key));
const oldItems = items || [];
const keysEqual =
oldItems.length === newItems.length && oldItems.every((i) => newKeys.has(i.key));
oldItems.length === newItems.length &&
oldItems.every((i) => newItems.some((n) => n.key === i.key));
if (enabled !== this.state.enabled || !keysEqual) {
this.setContainerDisplay(enabled);
this.setState({ items: newItems, enabled });
if (newEnabled !== enabled || !keysEqual) {
setContainerDisplay(newEnabled);
setItems(newItems);
setEnabled(newEnabled);
}
};
EventBus.on('refresh', this.handleRefresh);
}
EventBus.on('refresh', handleRefresh);
return () => {
EventBus.off('refresh', handleRefresh);
};
}, [enabled, items]);
componentDidUpdate(prevProps, prevState) {
if (prevState.enabled !== this.state.enabled) {
this.setContainerDisplay(this.state.enabled);
}
}
const QUICKLINKS_SECTION = 'modals.main.settings.sections.quicklinks';
componentWillUnmount() {
if (this.handleRefresh) {
EventBus.off('refresh', this.handleRefresh);
} else {
try {
EventBus.off('refresh');
} catch {
// Ignore errors
}
}
}
render() {
const QUICKLINKS_SECTION = 'modals.main.settings.sections.quicklinks';
const { enabled } = this.state;
const AdditionalSettings = () => (
<Row>
<Content
title={variables.getMessage('modals.main.settings.additional_settings')}
subtitle={variables.getMessage(`${QUICKLINKS_SECTION}.additional`)}
/>
<Action>
<Checkbox
name="quicklinksnewtab"
text={variables.getMessage(`${QUICKLINKS_SECTION}.open_new`)}
category="quicklinks"
/>
<Checkbox
name="quicklinkstooltip"
text={variables.getMessage(`${QUICKLINKS_SECTION}.tooltip`)}
category="quicklinks"
/>
</Action>
</Row>
);
const StylingOptions = () => (
<Row>
<Content
title={variables.getMessage(`${QUICKLINKS_SECTION}.styling`)}
subtitle={variables.getMessage(
'modals.main.settings.sections.quicklinks.styling_description',
)}
/>
<Action>
<Dropdown
label={variables.getMessage(`${QUICKLINKS_SECTION}.style`)}
name="quickLinksStyle"
category="quicklinks"
items={[
{ value: 'icon', text: variables.getMessage(`${QUICKLINKS_SECTION}.options.icon`) },
{
value: 'text_only',
text: variables.getMessage(`${QUICKLINKS_SECTION}.options.text_only`),
},
{ value: 'metro', text: variables.getMessage(`${QUICKLINKS_SECTION}.options.metro`) },
]}
/>
</Action>
</Row>
);
const AddLink = () => (
<Row final={true}>
<Content title={variables.getMessage(`${QUICKLINKS_SECTION}.title`)} />
<Action>
<Button
type="settings"
onClick={() => enabled && this.setState({ showAddModal: true })}
icon={<MdAddLink />}
label={variables.getMessage(`${QUICKLINKS_SECTION}.add_link`)}
disabled={!enabled}
/>
</Action>
</Row>
);
return (
<>
<Header
title={variables.getMessage(`${QUICKLINKS_SECTION}.title`)}
setting="quicklinksenabled"
const AdditionalSettings = () => (
<Row>
<Content
title={variables.getMessage('modals.main.settings.additional_settings')}
subtitle={variables.getMessage(`${QUICKLINKS_SECTION}.additional`)}
/>
<Action>
<Checkbox
name="quicklinksnewtab"
text={variables.getMessage(`${QUICKLINKS_SECTION}.open_new`)}
category="quicklinks"
element=".quicklinks-container"
zoomSetting="zoomQuicklinks"
visibilityToggle={true}
/>
<PreferencesWrapper
setting="quicklinksenabled"
<Checkbox
name="quicklinkstooltip"
text={variables.getMessage(`${QUICKLINKS_SECTION}.tooltip`)}
category="quicklinks"
visibilityToggle={true}
zoomSetting="zoomQuicklinks"
>
<AdditionalSettings />
<StylingOptions />
<AddLink />
/>
</Action>
</Row>
);
{this.state.items.length === 0 && (
<div className="photosEmpty">
<div className="emptyNewMessage">
<MdLinkOff />
<span className="title">
{variables.getMessage(`${QUICKLINKS_SECTION}.no_quicklinks`)}
</span>
<span className="subtitle">
{variables.getMessage('modals.main.settings.sections.message.add_some')}
</span>
<Button
type="settings"
onClick={() => this.setState({ showAddModal: true })}
icon={<MdAddLink />}
label={variables.getMessage(`${QUICKLINKS_SECTION}.add_link`)}
/>
</div>
const StylingOptions = () => (
<Row>
<Content
title={variables.getMessage(`${QUICKLINKS_SECTION}.styling`)}
subtitle={variables.getMessage(
'modals.main.settings.sections.quicklinks.styling_description',
)}
/>
<Action>
<Dropdown
label={variables.getMessage(`${QUICKLINKS_SECTION}.style`)}
name="quickLinksStyle"
category="quicklinks"
items={[
{ value: 'icon', text: variables.getMessage(`${QUICKLINKS_SECTION}.options.icon`) },
{
value: 'text_only',
text: variables.getMessage(`${QUICKLINKS_SECTION}.options.text_only`),
},
{ value: 'metro', text: variables.getMessage(`${QUICKLINKS_SECTION}.options.metro`) },
]}
/>
</Action>
</Row>
);
const AddLink = () => (
<Row final={true}>
<Content title={variables.getMessage(`${QUICKLINKS_SECTION}.title`)} />
<Action>
<Button
type="settings"
onClick={() => enabled && setShowAddModal(true)}
icon={<MdAddLink />}
label={variables.getMessage(`${QUICKLINKS_SECTION}.add_link`)}
disabled={!enabled}
/>
</Action>
</Row>
);
return (
<>
<Header
title={variables.getMessage(`${QUICKLINKS_SECTION}.title`)}
setting="quicklinksenabled"
category="quicklinks"
element=".quicklinks-container"
zoomSetting="zoomQuicklinks"
visibilityToggle={true}
/>
<PreferencesWrapper
setting="quicklinksenabled"
category="quicklinks"
visibilityToggle={true}
zoomSetting="zoomQuicklinks"
>
<AdditionalSettings />
<StylingOptions />
<AddLink />
{items.length === 0 && (
<div className="photosEmpty">
<div className="emptyNewMessage">
<MdLinkOff />
<span className="title">
{variables.getMessage(`${QUICKLINKS_SECTION}.no_quicklinks`)}
</span>
<span className="subtitle">
{variables.getMessage('modals.main.settings.sections.message.add_some')}
</span>
<Button
type="settings"
onClick={() => setShowAddModal(true)}
icon={<MdAddLink />}
label={variables.getMessage(`${QUICKLINKS_SECTION}.add_link`)}
/>
</div>
)}
</PreferencesWrapper>
<div
className={`quicklinks-container ${!enabled ? 'disabled' : ''}`}
ref={this.quicklinksContainer}
aria-hidden={!enabled}
>
<div className={`messagesContainer ${!enabled ? 'disabled' : ''}`}>
<SortableList
items={this.state.items}
enabled={enabled}
onDragEnd={this.handleDragEnd}
startEditLink={(data) => this.startEditLink(data)}
deleteLink={(key, e) => this.deleteLink(key, e)}
/>
</div>
</div>
<Modal
closeTimeoutMS={100}
onRequestClose={() => this.setState({ showAddModal: false, urlError: '', iconError: '' })}
isOpen={this.state.showAddModal}
className="Modal resetmodal mainModal"
overlayClassName="Overlay resetoverlay"
ariaHideApp={false}
>
<AddModal
urlError={this.state.urlError}
addLink={(name, url, icon) => this.addLink(name, url, icon)}
editLink={(og, name, url, icon) => this.editLink(og, name, url, icon)}
edit={this.state.edit}
editData={this.state.editData}
closeModal={() =>
this.setState({ showAddModal: false, urlError: '', iconError: '', edit: false })
}
)}
</PreferencesWrapper>
<div
className={`quicklinks-container ${!enabled ? 'disabled' : ''}`}
ref={quicklinksContainer}
aria-hidden={!enabled}
>
<div className={`messagesContainer ${!enabled ? 'disabled' : ''}`}>
<SortableList
items={items}
enabled={enabled}
onDragEnd={handleDragEnd}
startEditLink={(data) => startEditLink(data)}
deleteLink={(key, e) => deleteLink(key, e)}
/>
</Modal>
</>
);
}
}
</div>
</div>
<Modal
closeTimeoutMS={100}
onRequestClose={() => {
setShowAddModal(false);
setUrlError('');
setIconError('');
}}
isOpen={showAddModal}
className="Modal resetmodal mainModal"
overlayClassName="Overlay resetoverlay"
ariaHideApp={false}
>
<AddModal
urlError={urlError}
addLink={(name, url, icon) => addLink(name, url, icon)}
editLink={(og, name, url, icon) => editLink(og, name, url, icon)}
edit={edit}
editData={editData}
closeModal={() => {
setShowAddModal(false);
setUrlError('');
setIconError('');
setEdit(false);
}}
/>
</Modal>
</>
);
};
export { QuickLinksOptions as default, QuickLinksOptions };

View File

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

View File

@@ -0,0 +1,74 @@
import { MdEdit, MdDelete } from 'react-icons/md';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { DragHandle } from './DragHandle';
export const SortableItem = ({ value, enabled, startEditLink, deleteLink }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: value.key,
disabled: !enabled,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const getIconUrl = (item) => {
return (
item.icon ||
'https://icon.horse/icon/' + item.url.replace('https://', '').replace('http://', '')
);
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={`quicklink-item ${!enabled ? 'disabled' : ''}`}
role="listitem"
>
<DragHandle />
<div className="quicklink-icon">
<img src={getIconUrl(value)} alt={value.name} draggable={false} />
</div>
<div className="quicklink-content">
<div className="quicklink-name">{value.name}</div>
<div className="quicklink-url">{value.url}</div>
</div>
<div className="quicklink-actions">
<button
className="quicklink-action-btn"
onClick={(e) => {
if (!enabled) return;
e.stopPropagation();
startEditLink(value);
}}
title="Edit"
disabled={!enabled}
aria-disabled={!enabled}
>
<MdEdit />
<span>Edit</span>
</button>
<button
className="quicklink-action-btn quicklink-remove-btn"
onClick={(e) => {
if (!enabled) return;
e.stopPropagation();
deleteLink(value.key, e);
}}
title="Remove"
disabled={!enabled}
aria-disabled={!enabled}
>
<MdDelete />
<span>Remove</span>
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,39 @@
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { SortableItem } from './SortableItem';
export const SortableList = ({ items, enabled, onDragEnd, startEditLink, deleteLink }) => {
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="quicklinks-list" role="list">
{items.map((item) => (
<SortableItem
key={item.key}
value={item}
enabled={enabled}
startEditLink={startEditLink}
deleteLink={deleteLink}
/>
))}
</div>
</SortableContext>
</DndContext>
);
};

View File

@@ -0,0 +1,3 @@
export * from './DragHandle';
export * from './SortableItem';
export * from './SortableList';

View File

@@ -0,0 +1,11 @@
export const readQuicklinks = () => {
try {
const raw = localStorage.getItem('quicklinks');
if (!raw) return [];
const data = JSON.parse(raw);
return Array.isArray(data) ? data : [];
} catch (e) {
console.warn('Failed to parse quicklinks from localStorage. Resetting to []', e);
return [];
}
};