feat(quicklinks): add drag-and-drop reordering for Quick Links (#1102)

* feat(quicklinks): enable drag-and-drop reordering for Quick Links

* fix(quicklinks): some bug fixes
This commit is contained in:
Fawaz Khan
2025-10-14 14:28:49 +05:30
committed by GitHub
parent b84e4400ae
commit 51d2791c65
8 changed files with 2326 additions and 7533 deletions

View File

@@ -1,6 +1,7 @@
import variables from 'config/variables';
import { PureComponent, createRef } from 'react';
import { MdAddLink, MdLinkOff } from 'react-icons/md';
import { MdAddLink, MdLinkOff, MdOutlineDragIndicator, MdEdit, MdDelete } from 'react-icons/md';
import { sortableContainer, sortableElement } from '@muetab/react-sortable-hoc';
import { Header, Row, Content, Action, PreferencesWrapper } from 'components/Layout/Settings';
import { Checkbox, Dropdown } from 'components/Form/Settings';
import { Button } from 'components/Elements';
@@ -9,194 +10,296 @@ import Modal from 'react-modal';
import { AddModal } from 'components/Elements/AddModal';
import EventBus from 'utils/eventbus';
import { QuickLink } from './QuickLink';
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 DragHandle = () => (
<div className="quicklink-drag-handle" aria-hidden="true">
<MdOutlineDragIndicator />
</div>
);
const SortableItem = sortableElement(({ value, enabled, startEditLink, deleteLink }) => {
const getIconUrl = (item) => {
return item.icon || 'https://icon.horse/icon/' + item.url.replace('https://', '').replace('http://', '');
};
return (
<div 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 SortableContainer = sortableContainer(({ children }) => (
<div className="quicklinks-list" role="list">{children}</div>
));
class QuickLinksOptions extends PureComponent {
constructor() {
super();
this.state = {
items: JSON.parse(localStorage.getItem('quicklinks')),
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;
el.classList.toggle('disabled', !enabled);
if (!enabled) {
el.setAttribute('aria-hidden', 'true');
} else {
el.removeAttribute('aria-hidden');
}
}
deleteLink(key, event) {
event.preventDefault();
// remove link from array
const data = JSON.parse(localStorage.getItem('quicklinks')).filter((i) => i.key !== key);
localStorage.setItem('quicklinks', JSON.stringify(data));
this.setState({
items: data,
});
event.preventDefault();
const stored = readQuicklinks();
const data = stored.filter((i) => i.key !== key);
this.silenceEvent = 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);
});
}
async addLink(name, url, icon) {
const data = JSON.parse(localStorage.getItem('quicklinks'));
const data = readQuicklinks();
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
if (url.length <= 0 || isValidUrl(url) === false) {
return this.setState({
urlError: variables.getMessage('widgets.quicklinks.url_error'),
});
}
if (icon.length > 0 && isValidUrl(icon) === false) {
return this.setState({
iconError: variables.getMessage('widgets.quicklinks.url_error'),
});
}
data.push({
name: name || (await getTitleFromUrl(url)),
url,
icon: icon || '',
key: Math.random().toString(36).substring(7) + 1,
});
localStorage.setItem('quicklinks', JSON.stringify(data));
this.setState({
items: data,
showAddModal: false,
urlError: '',
iconError: '',
});
variables.stats.postEvent('feature', 'Quicklink add');
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
if (url.length <= 0 || isValidUrl(url) === false) {
return this.setState({ urlError: variables.getMessage('widgets.quicklinks.url_error') });
}
if (icon.length > 0 && isValidUrl(icon) === false) {
return this.setState({ iconError: variables.getMessage('widgets.quicklinks.url_error') });
}
const newItem = {
name: name || (await getTitleFromUrl(url)),
url,
icon: icon || '',
key: Date.now().toString() + Math.random().toString(36).substring(2),
};
data.push(newItem);
this.silenceEvent = 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);
});
}
startEditLink(data) {
this.setState({
edit: true,
editData: data,
showAddModal: true,
});
this.setState({ edit: true, editData: data, showAddModal: true });
}
async editLink(og, name, url, icon) {
const data = JSON.parse(localStorage.getItem('quicklinks'));
const dataobj = data.find((i) => i.key === og.key);
dataobj.name = name || (await getTitleFromUrl(url));
dataobj.url = url;
dataobj.icon = icon || '';
const data = readQuicklinks();
const dataobj = data.find((i) => i.key === og.key);
if (!dataobj) return;
localStorage.setItem('quicklinks', JSON.stringify(data));
dataobj.name = name || (await getTitleFromUrl(url));
dataobj.url = url;
dataobj.icon = icon || '';
this.setState({
items: data,
showAddModal: false,
edit: false,
});
this.silenceEvent = 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;
};
onSortStart = () => {
if (this.quicklinksContainer && this.quicklinksContainer.current) {
this.quicklinksContainer.current.classList.add('dragging');
}
};
onSortEnd = ({ oldIndex, newIndex }) => {
if (!this.state.enabled) return;
if (oldIndex === newIndex) return;
const newItems = this.arrayMove(this.state.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);
});
};
componentDidMount() {
EventBus.on('refresh', (data) => {
if (data === 'quicklinks') {
if (localStorage.getItem('quicklinksenabled') === 'false') {
return (this.quicklinksContainer.current.style.display = 'none');
}
this.setContainerDisplay(this.state.enabled);
this.quicklinksContainer.current.style.display = 'block';
this.handleRefresh = (data) => {
if (data !== 'quicklinks') return;
if (this.silenceEvent) return;
this.setState({
items: JSON.parse(localStorage.getItem('quicklinks')),
});
}
});
const enabled = 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 keysEqual =
oldItems.length === newItems.length &&
oldItems.every(i => newKeys.has(i.key));
if (enabled !== this.state.enabled || !keysEqual) {
this.setContainerDisplay(enabled);
this.setState({ items: newItems, enabled });
}
};
EventBus.on('refresh', this.handleRefresh);
}
componentDidUpdate(prevProps, prevState) {
if (prevState.enabled !== this.state.enabled) {
this.setContainerDisplay(this.state.enabled);
}
}
componentWillUnmount() {
EventBus.off('refresh');
componentWillUnmount() {
if (this.handleRefresh) {
EventBus.off('refresh', this.handleRefresh);
} else {
try { EventBus.off('refresh'); } catch (e) {}
}
}
render() {
const QUICKLINKS_SECTION = 'modals.main.settings.sections.quicklinks';
const { enabled } = this.state;
const AdditionalSettings = () => {
return (
<Row>
<Content
title={variables.getMessage('modals.main.settings.additional_settings')}
subtitle={variables.getMessage(`${QUICKLINKS_SECTION}.additional`)}
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>
<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>
);
};
</Action>
</Row>
);
const StylingOptions = () => {
return (
<Row>
<Content
title={variables.getMessage(`${QUICKLINKS_SECTION}.styling`)}
subtitle={variables.getMessage(
'modals.main.settings.sections.quicklinks.styling_description',
)}
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>
<Dropdown
label={variables.getMessage(`${QUICKLINKS_SECTION}.style`)}
name="quickLinksStyle"
category="quicklinks"
items={[
{
value: 'icon',
text: variables.getMessage(`${QUICKLINKS_SECTION}.options.icon`),
},
{
value: 'text',
text: variables.getMessage(`${QUICKLINKS_SECTION}.options.text_only`),
},
{
value: 'metro',
text: variables.getMessage(`${QUICKLINKS_SECTION}.options.metro`),
},
]}
/>
</Action>
</Row>
);
};
const AddLink = () => {
return (
<Row final={true}>
<Content title={variables.getMessage(`${QUICKLINKS_SECTION}.title`)} />
<Action>
<Button
type="settings"
onClick={() => this.setState({ showAddModal: true })}
icon={<MdAddLink />}
label={variables.getMessage(`${QUICKLINKS_SECTION}.add_link`)}
/>
</Action>
</Row>
);
};
</Action>
</Row>
);
return (
<>
@@ -208,6 +311,7 @@ class QuickLinksOptions extends PureComponent {
zoomSetting="zoomQuicklinks"
visibilityToggle={true}
/>
<PreferencesWrapper
setting="quicklinksenabled"
category="quicklinks"
@@ -222,33 +326,43 @@ class QuickLinksOptions extends PureComponent {
<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`)}
/>
<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>
</div>
)}
</PreferencesWrapper>
<div className="messagesContainer" ref={this.quicklinksContainer}>
{this.state.items.map((item, i) => (
<QuickLink
key={i}
item={item}
startEditLink={() => this.startEditLink(item)}
deleteLink={(key, e) => this.deleteLink(key, e)}
/>
))}
<div
className={`quicklinks-container ${!enabled ? 'disabled' : ''}`}
ref={this.quicklinksContainer}
aria-hidden={!enabled}
>
<div className={`messagesContainer ${!enabled ? 'disabled' : ''}`}>
<SortableContainer
onSortStart={this.onSortStart}
onSortEnd={this.onSortEnd}
lockAxis="y"
lockToContainerEdges
disableAutoscroll
helperClass="sortable-helper"
distance={6}
disabled={!enabled}
>
{this.state.items.map((item, index) => (
<SortableItem
key={item.key}
index={index}
value={item}
enabled={enabled}
startEditLink={(data) => this.startEditLink(data)}
deleteLink={(key, e) => this.deleteLink(key, e)}
/>
))}
</SortableContainer>
</div>
</div>
<Modal
closeTimeoutMS={100}
onRequestClose={() => this.setState({ showAddModal: false, urlError: '', iconError: '' })}
@@ -263,9 +377,7 @@ class QuickLinksOptions extends PureComponent {
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 })
}
closeModal={() => this.setState({ showAddModal: false, urlError: '', iconError: '', edit: false })}
/>
</Modal>
</>
@@ -273,4 +385,4 @@ class QuickLinksOptions extends PureComponent {
}
}
export { QuickLinksOptions as default, QuickLinksOptions };
export { QuickLinksOptions as default, QuickLinksOptions };