import React, { useState, useEffect } from 'react'; import { Location } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { QueueListIcon } from '@heroicons/react/24/outline'; import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { getApiPath } from '../../config/paths'; interface View { id: number; uid: string; name: string; is_pinned: boolean; } interface SidebarViewsProps { handleNavClick: (path: string, title: string, icon: JSX.Element) => void; location: Location; isDarkMode: boolean; } interface SortableViewItemProps { view: View; isActive: boolean; onNavigate: () => void; onTogglePin: (e: React.MouseEvent) => void; } const SortableViewItem: React.FC = ({ view, isActive, onNavigate, onTogglePin, }) => { const { t } = useTranslation(); const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: view.uid }); const style = { transform: CSS.Transform.toString(transform), transition, }; const handleClick = (e: React.MouseEvent) => { // Don't navigate if clicking the star button if ((e.target as HTMLElement).closest('button')) { return; } onNavigate(); }; return (
  • {view.name}
  • ); }; const SidebarViews: React.FC = ({ handleNavClick, location, }) => { const { t } = useTranslation(); const [pinnedViews, setPinnedViews] = useState([]); const [sidebarSettings, setSidebarSettings] = useState<{ pinnedViewsOrder: string[]; }>({ pinnedViewsOrder: [] }); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { delay: 250, // 250ms press before drag activates tolerance: 5, // 5px movement tolerance during delay }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); useEffect(() => { fetchUserSettings(); fetchPinnedViews(); // Listen for view updates const handleViewUpdate = () => { fetchPinnedViews(); }; window.addEventListener('viewUpdated', handleViewUpdate); return () => { window.removeEventListener('viewUpdated', handleViewUpdate); }; }, []); const fetchUserSettings = async () => { try { const response = await fetch(getApiPath('profile'), { credentials: 'include', }); if (response.ok) { const profile = await response.json(); if (profile.sidebar_settings) { // Parse if it's a string (from SQLite JSON storage) const settings = typeof profile.sidebar_settings === 'string' ? JSON.parse(profile.sidebar_settings) : profile.sidebar_settings; setSidebarSettings(settings); } } } catch (error) { console.error('Error fetching user settings:', error); } }; const fetchPinnedViews = async () => { try { const response = await fetch(getApiPath('views/pinned'), { credentials: 'include', }); if (response.ok) { const views = await response.json(); setPinnedViews(views); } } catch (error) { console.error('Error fetching pinned views:', error); } }; const togglePin = async (view: View, e: React.MouseEvent) => { e.stopPropagation(); try { const response = await fetch(getApiPath(`views/${view.uid}`), { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ is_pinned: !view.is_pinned, }), }); if (response.ok) { fetchPinnedViews(); } } catch (error) { console.error('Error toggling pin:', error); } }; const handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { const oldIndex = orderedViews.findIndex((v) => v.uid === active.id); const newIndex = orderedViews.findIndex((v) => v.uid === over.id); const newOrder = arrayMove(orderedViews, oldIndex, newIndex); const newOrderUids = newOrder.map((v) => v.uid); // Optimistically update UI setSidebarSettings({ pinnedViewsOrder: newOrderUids }); // Save to backend try { await fetch(getApiPath('profile/sidebar-settings'), { method: 'PUT', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ pinnedViewsOrder: newOrderUids, }), }); } catch (error) { console.error('Error saving view order:', error); // Revert on error fetchUserSettings(); } } }; // Sort views based on saved order const orderedViews = React.useMemo(() => { if (!sidebarSettings.pinnedViewsOrder.length) { return pinnedViews; } const orderMap = new Map( sidebarSettings.pinnedViewsOrder.map((uid, index) => [uid, index]) ); return [...pinnedViews].sort((a, b) => { const indexA = orderMap.get(a.uid); const indexB = orderMap.get(b.uid); // If both have saved order, use that if (indexA !== undefined && indexB !== undefined) { return indexA - indexB; } // If only one has saved order, it comes first if (indexA !== undefined) return -1; if (indexB !== undefined) return 1; // Otherwise maintain original order return 0; }); }, [pinnedViews, sidebarSettings.pinnedViewsOrder]); const isActiveView = (path: string) => { return location.pathname === path; }; return ( <>
      {/* "VIEWS" Title */}
    • handleNavClick( '/views', t('sidebar.views'), ) } > {t('sidebar.views')}
    • {/* Pinned Views with Drag and Drop */} {orderedViews.length > 0 &&
      } v.uid)} strategy={verticalListSortingStrategy} > {orderedViews.map((view) => ( handleNavClick( `/views/${view.uid}`, view.name, ) } onTogglePin={(e) => togglePin(view, e)} /> ))}
    ); }; export default SidebarViews;