mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(sidebar): long-press drag to reorder and hide nav items (#269)
Hold for 1s then drag to reorder configurable library and system links; drop outside the sidebar to hide items (same visibility as Settings). Show a trash hint near the cursor when outside removal applies. Extract shared reorder helpers into src/utils/sidebarNavReorder.ts and reuse them from the Settings sidebar customizer.
This commit is contained in:
+376
-37
@@ -5,13 +5,13 @@ import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSidebarStore } from '../store/sidebarStore';
|
||||
import { useSidebarStore, type SidebarItemConfig } from '../store/sidebarStore';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Settings,
|
||||
PanelLeftClose, PanelLeft, AudioLines, HardDriveDownload, HardDriveUpload,
|
||||
ChevronDown, Check, Music2, X, ChevronRight, PlayCircle,
|
||||
ChevronDown, Check, Music2, X, ChevronRight, PlayCircle, Trash2,
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
@@ -20,6 +20,23 @@ import { getPlaylists } from '../api/subsonic';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||
import OverlayScrollArea from './OverlayScrollArea';
|
||||
import {
|
||||
applySidebarDropReorder,
|
||||
getLibraryItemsForReorder,
|
||||
getSystemItemsForReorder,
|
||||
isSidebarNavItemUserHideable,
|
||||
type SidebarNavDropTarget,
|
||||
} from '../utils/sidebarNavReorder';
|
||||
|
||||
const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
|
||||
const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
|
||||
|
||||
function isPointerOutsideAsideSidebar(clientX: number, clientY: number): boolean {
|
||||
const aside = document.querySelector('aside.sidebar');
|
||||
if (!aside) return false;
|
||||
const r = aside.getBoundingClientRect();
|
||||
return clientX < r.left || clientX > r.right || clientY < r.top || clientY > r.bottom;
|
||||
}
|
||||
|
||||
|
||||
export default function Sidebar({
|
||||
@@ -49,6 +66,7 @@ export default function Sidebar({
|
||||
const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const setSidebarItems = useSidebarStore(s => s.setItems);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
|
||||
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
|
||||
@@ -68,6 +86,239 @@ export default function Sidebar({
|
||||
filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null;
|
||||
const libraryTriggerPlain = filterId === 'all';
|
||||
|
||||
const libraryItemsForReorder = useMemo(
|
||||
() => getLibraryItemsForReorder(sidebarItems, randomNavMode),
|
||||
[sidebarItems, randomNavMode],
|
||||
);
|
||||
const systemItemsForReorder = useMemo(
|
||||
() => getSystemItemsForReorder(sidebarItems),
|
||||
[sidebarItems],
|
||||
);
|
||||
const visibleLibraryConfigs = useMemo(
|
||||
() => libraryItemsForReorder.filter(c => c.visible),
|
||||
[libraryItemsForReorder],
|
||||
);
|
||||
const visibleSystemConfigs = useMemo(
|
||||
() => systemItemsForReorder.filter(c => c.visible),
|
||||
[systemItemsForReorder],
|
||||
);
|
||||
|
||||
const [navDnd, setNavDnd] = useState<{
|
||||
section: 'library' | 'system';
|
||||
fromIdx: number;
|
||||
} | null>(null);
|
||||
const [navDropTarget, setNavDropTarget] = useState<SidebarNavDropTarget | null>(null);
|
||||
const navDropTargetRef = useRef<SidebarNavDropTarget | null>(null);
|
||||
navDropTargetRef.current = navDropTarget;
|
||||
const sidebarItemsRef = useRef(sidebarItems);
|
||||
sidebarItemsRef.current = sidebarItems;
|
||||
const randomNavModeRef = useRef(randomNavMode);
|
||||
randomNavModeRef.current = randomNavMode;
|
||||
/** DOM timers are numeric; avoid NodeJS `Timeout` typing from `setTimeout`. */
|
||||
const longPressTimersRef = useRef<Map<number, number>>(new Map());
|
||||
const suppressNavClickRef = useRef(false);
|
||||
const lastPointerDuringNavDndRef = useRef({ x: 0, y: 0 });
|
||||
const [navDndTrashHint, setNavDndTrashHint] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!navDnd) return;
|
||||
|
||||
const updateDropFromPoint = (clientX: number, clientY: number) => {
|
||||
if (isPointerOutsideAsideSidebar(clientX, clientY)) {
|
||||
navDropTargetRef.current = null;
|
||||
setNavDropTarget(null);
|
||||
return;
|
||||
}
|
||||
const rows = document.querySelectorAll<HTMLElement>('.sidebar [data-sidebar-nav-dnd-row]');
|
||||
let target: SidebarNavDropTarget | null = null;
|
||||
for (const row of rows) {
|
||||
const section = row.dataset.sidebarSection as 'library' | 'system' | undefined;
|
||||
if (section !== navDnd.section) continue;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const idx = Number(row.dataset.sidebarIdx);
|
||||
if (Number.isNaN(idx)) continue;
|
||||
if (clientY < rect.top + rect.height / 2) {
|
||||
target = { idx, before: true, section };
|
||||
break;
|
||||
}
|
||||
target = { idx, before: false, section };
|
||||
}
|
||||
navDropTargetRef.current = target;
|
||||
setNavDropTarget(target);
|
||||
};
|
||||
|
||||
const endDrag = (apply: boolean) => {
|
||||
window.removeEventListener('pointermove', onMove, { capture: true });
|
||||
window.removeEventListener('pointerup', onUp, true);
|
||||
window.removeEventListener('pointercancel', onUp, true);
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
document.body.style.userSelect = '';
|
||||
setNavDndTrashHint(null);
|
||||
|
||||
const currentDnd = navDnd;
|
||||
const drop = navDropTargetRef.current;
|
||||
setNavDnd(null);
|
||||
setNavDropTarget(null);
|
||||
navDropTargetRef.current = null;
|
||||
|
||||
if (!apply || !currentDnd) return;
|
||||
|
||||
const { x, y } = lastPointerDuringNavDndRef.current;
|
||||
if (isPointerOutsideAsideSidebar(x, y)) {
|
||||
const sectionItems =
|
||||
currentDnd.section === 'library'
|
||||
? getLibraryItemsForReorder(sidebarItemsRef.current, randomNavModeRef.current)
|
||||
: getSystemItemsForReorder(sidebarItemsRef.current);
|
||||
const id = sectionItems[currentDnd.fromIdx]?.id;
|
||||
if (id && isSidebarNavItemUserHideable(id)) {
|
||||
const nextItems: SidebarItemConfig[] = sidebarItemsRef.current.map(i =>
|
||||
i.id === id ? { ...i, visible: false } : i,
|
||||
);
|
||||
setSidebarItems(nextItems);
|
||||
suppressNavClickRef.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const next = applySidebarDropReorder(
|
||||
sidebarItemsRef.current,
|
||||
currentDnd.section,
|
||||
currentDnd.fromIdx,
|
||||
drop,
|
||||
randomNavModeRef.current,
|
||||
);
|
||||
if (next) {
|
||||
setSidebarItems(next);
|
||||
suppressNavClickRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
e.preventDefault();
|
||||
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
|
||||
|
||||
const outside = isPointerOutsideAsideSidebar(e.clientX, e.clientY);
|
||||
const sectionItems =
|
||||
navDnd.section === 'library'
|
||||
? getLibraryItemsForReorder(sidebarItemsRef.current, randomNavModeRef.current)
|
||||
: getSystemItemsForReorder(sidebarItemsRef.current);
|
||||
const draggedId = sectionItems[navDnd.fromIdx]?.id;
|
||||
const canTrash = Boolean(draggedId && isSidebarNavItemUserHideable(draggedId));
|
||||
if (outside && canTrash) {
|
||||
setNavDndTrashHint({ x: e.clientX, y: e.clientY });
|
||||
} else {
|
||||
setNavDndTrashHint(null);
|
||||
}
|
||||
|
||||
updateDropFromPoint(e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
const onUp = (e: PointerEvent) => {
|
||||
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
|
||||
endDrag(true);
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
endDrag(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.body.style.userSelect = 'none';
|
||||
window.addEventListener('pointermove', onMove, { capture: true, passive: false });
|
||||
window.addEventListener('pointerup', onUp, true);
|
||||
window.addEventListener('pointercancel', onUp, true);
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onMove, { capture: true });
|
||||
window.removeEventListener('pointerup', onUp, true);
|
||||
window.removeEventListener('pointercancel', onUp, true);
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
document.body.style.userSelect = '';
|
||||
setNavDndTrashHint(null);
|
||||
};
|
||||
}, [navDnd, setSidebarItems]);
|
||||
|
||||
const handleNavRowPointerDown = useCallback(
|
||||
(e: React.PointerEvent, section: 'library' | 'system', sectionIdx: number) => {
|
||||
if (isCollapsed || navDnd) return;
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
|
||||
const pid = e.pointerId;
|
||||
const sx = e.clientX;
|
||||
const sy = e.clientY;
|
||||
|
||||
let cleaned = false;
|
||||
const cleanupEarly = () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
document.removeEventListener('pointermove', onEarlyMove);
|
||||
document.removeEventListener('pointerup', onEarlyUp, true);
|
||||
document.removeEventListener('pointercancel', onEarlyUp, true);
|
||||
};
|
||||
|
||||
const onEarlyMove = (ev: PointerEvent) => {
|
||||
if (ev.pointerId !== pid) return;
|
||||
if (Math.hypot(ev.clientX - sx, ev.clientY - sy) > SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX) {
|
||||
const t = longPressTimersRef.current.get(pid);
|
||||
if (t != null) window.clearTimeout(t);
|
||||
longPressTimersRef.current.delete(pid);
|
||||
cleanupEarly();
|
||||
}
|
||||
};
|
||||
|
||||
const onEarlyUp = (ev: PointerEvent) => {
|
||||
if (ev.pointerId !== pid) return;
|
||||
const t = longPressTimersRef.current.get(pid);
|
||||
if (t != null) window.clearTimeout(t);
|
||||
longPressTimersRef.current.delete(pid);
|
||||
cleanupEarly();
|
||||
};
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
longPressTimersRef.current.delete(pid);
|
||||
cleanupEarly();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
lastPointerDuringNavDndRef.current = { x: sx, y: sy };
|
||||
setNavDnd({ section, fromIdx: sectionIdx });
|
||||
navDropTargetRef.current = { idx: sectionIdx, before: true, section };
|
||||
setNavDropTarget({ idx: sectionIdx, before: true, section });
|
||||
try {
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(pid);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, SIDEBAR_NAV_LONG_PRESS_MS) as unknown as number;
|
||||
|
||||
longPressTimersRef.current.set(pid, timer);
|
||||
document.addEventListener('pointermove', onEarlyMove);
|
||||
document.addEventListener('pointerup', onEarlyUp, true);
|
||||
document.addEventListener('pointercancel', onEarlyUp, true);
|
||||
},
|
||||
[isCollapsed, navDnd],
|
||||
);
|
||||
|
||||
const navDndRowClass = useCallback(
|
||||
(section: 'library' | 'system', sectionIdx: number) => {
|
||||
const dragging = navDnd?.section === section && navDnd.fromIdx === sectionIdx;
|
||||
let drop = '';
|
||||
if (
|
||||
navDnd &&
|
||||
navDropTarget?.section === section &&
|
||||
navDropTarget.idx === sectionIdx &&
|
||||
!(navDnd.section === section && navDnd.fromIdx === sectionIdx)
|
||||
) {
|
||||
drop = navDropTarget.before
|
||||
? 'sidebar-nav-dnd-row--drop-before'
|
||||
: 'sidebar-nav-dnd-row--drop-after';
|
||||
}
|
||||
return `sidebar-nav-dnd-row${dragging ? ' sidebar-nav-dnd-row--dragging' : ''}${drop ? ` ${drop}` : ''}`.trim();
|
||||
},
|
||||
[navDnd, navDropTarget],
|
||||
);
|
||||
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
const el = libraryTriggerRef.current;
|
||||
if (!el) return;
|
||||
@@ -122,22 +373,13 @@ export default function Sidebar({
|
||||
fetchPlaylists();
|
||||
}, [playlistsExpanded, isLoggedIn, fetchPlaylists]);
|
||||
|
||||
// Resolve ordered, visible items per section from store config
|
||||
const visibleLibrary = sidebarItems
|
||||
.filter(cfg => {
|
||||
if (cfg == null || !cfg.visible || ALL_NAV_ITEMS[cfg.id]?.section !== 'library') return false;
|
||||
// Hide mode-inactive mix entries so the active mode controls what's shown
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||
return true;
|
||||
})
|
||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
||||
const visibleSystem = sidebarItems
|
||||
.filter(cfg => cfg != null && cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'system')
|
||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
||||
|
||||
useEffect(() => () => {
|
||||
longPressTimersRef.current.forEach(t => window.clearTimeout(t));
|
||||
longPressTimersRef.current.clear();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
<div className="sidebar-brand">
|
||||
{isCollapsed
|
||||
@@ -155,7 +397,17 @@ export default function Sidebar({
|
||||
{isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />}
|
||||
</button>
|
||||
|
||||
<nav className="sidebar-nav" aria-label="Main navigation">
|
||||
<nav
|
||||
className="sidebar-nav"
|
||||
aria-label="Main navigation"
|
||||
onClickCapture={e => {
|
||||
if (suppressNavClickRef.current) {
|
||||
suppressNavClickRef.current = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<OverlayScrollArea
|
||||
className="sidebar-nav-scroll"
|
||||
viewportClassName="sidebar-nav-viewport"
|
||||
@@ -248,10 +500,29 @@ export default function Sidebar({
|
||||
) : (
|
||||
<span className="nav-section-label">{t('sidebar.library')}</span>
|
||||
))}
|
||||
{visibleLibrary.map(item => (
|
||||
item.to === '/playlists' ? (
|
||||
// Playlists item with expand button
|
||||
<div key={item.to} className="sidebar-playlists-wrapper">
|
||||
{visibleLibraryConfigs.map(cfg => {
|
||||
const item = ALL_NAV_ITEMS[cfg.id];
|
||||
if (!item) return null;
|
||||
const sectionIdx = libraryItemsForReorder.findIndex(x => x.id === cfg.id);
|
||||
const dndRow = !isCollapsed && sectionIdx >= 0;
|
||||
const rowClass = dndRow ? navDndRowClass('library', sectionIdx) : undefined;
|
||||
const dndProps = dndRow
|
||||
? {
|
||||
'data-sidebar-nav-dnd-row': '',
|
||||
'data-sidebar-section': 'library' as const,
|
||||
'data-sidebar-idx': String(sectionIdx),
|
||||
onPointerDown: (e: React.PointerEvent) =>
|
||||
handleNavRowPointerDown(e, 'library', sectionIdx),
|
||||
}
|
||||
: {};
|
||||
|
||||
return item.to === '/playlists' ? (
|
||||
<div
|
||||
key={item.to}
|
||||
className={`sidebar-playlists-wrapper${rowClass ? ` ${rowClass}` : ''}`}
|
||||
{...dndProps}
|
||||
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
|
||||
>
|
||||
<div className="sidebar-playlists-header-row">
|
||||
<NavLink
|
||||
to={item.to}
|
||||
@@ -299,7 +570,7 @@ export default function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
) : isCollapsed ? (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
@@ -311,8 +582,26 @@ export default function Sidebar({
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
)
|
||||
))}
|
||||
) : (
|
||||
<div
|
||||
key={item.to}
|
||||
className={rowClass}
|
||||
{...dndProps}
|
||||
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
|
||||
>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Spacer: everything from here onward sticks to the bottom of the sidebar. */}
|
||||
<div className="sidebar-bottom-spacer" />
|
||||
@@ -346,19 +635,53 @@ export default function Sidebar({
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{visibleSystem.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
|
||||
{visibleSystem.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
{visibleSystemConfigs.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
|
||||
{visibleSystemConfigs.map(cfg => {
|
||||
const item = ALL_NAV_ITEMS[cfg.id];
|
||||
if (!item) return null;
|
||||
const sectionIdx = systemItemsForReorder.findIndex(x => x.id === cfg.id);
|
||||
const dndRow = !isCollapsed && sectionIdx >= 0;
|
||||
const rowClass = dndRow ? navDndRowClass('system', sectionIdx) : undefined;
|
||||
const dndProps = dndRow
|
||||
? {
|
||||
'data-sidebar-nav-dnd-row': '',
|
||||
'data-sidebar-section': 'system' as const,
|
||||
'data-sidebar-idx': String(sectionIdx),
|
||||
onPointerDown: (e: React.PointerEvent) =>
|
||||
handleNavRowPointerDown(e, 'system', sectionIdx),
|
||||
}
|
||||
: {};
|
||||
|
||||
return isCollapsed ? (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
) : (
|
||||
<div
|
||||
key={item.to}
|
||||
className={rowClass}
|
||||
{...dndProps}
|
||||
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
|
||||
>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
@@ -406,5 +729,21 @@ export default function Sidebar({
|
||||
</OverlayScrollArea>
|
||||
</nav>
|
||||
</aside>
|
||||
{navDndTrashHint != null &&
|
||||
createPortal(
|
||||
<div
|
||||
className="sidebar-nav-dnd-trash-hint"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: navDndTrashHint.x + 14,
|
||||
top: navDndTrashHint.y + 14,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
<Trash2 size={22} strokeWidth={2.25} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user