diff --git a/backend/app/core/settings_registry.py b/backend/app/core/settings_registry.py index 4647a0f..b564d68 100644 --- a/backend/app/core/settings_registry.py +++ b/backend/app/core/settings_registry.py @@ -275,6 +275,16 @@ register_setting(SettingDefinition( category="modules" )) +register_setting(SettingDefinition( + key="modules_order", + type=SettingType.LIST, + scope=SettingScope.GLOBAL, + storage=SettingStorage.DATABASE, + default=["feature1", "feature2", "feature3"], + description="Order of feature modules in sidebar", + category="modules" +)) + # ============================================================================= # AUTHENTICATION & SECURITY SETTINGS (Global, Database) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1c58843..e637051 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -59,13 +59,13 @@ export const settingsAPI = { return response.data; }, - getModules: async (): Promise> => { - const response = await api.get>('/settings/modules'); + getModules: async (): Promise> => { + const response = await api.get>('/settings/modules'); return response.data; }, - updateModules: async (data: Record): Promise> => { - const response = await api.put>('/settings/modules', data); + updateModules: async (data: Record): Promise> => { + const response = await api.put>('/settings/modules', data); return response.data; }, diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 7bc7611..c625f5c 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -27,14 +27,14 @@ export default function Sidebar() { } = useSidebar(); const { viewMode, toggleViewMode, isUserModeEnabled } = useViewMode(); const { user } = useAuth(); - const { isModuleEnabled, isModuleEnabledForUser, hasInitialized: modulesInitialized } = useModules(); + const { isModuleEnabled, isModuleEnabledForUser, moduleOrder, hasInitialized: modulesInitialized } = useModules(); // When admin is in "user mode", show only user-permitted modules // Otherwise, show all globally enabled modules (admin view) const shouldUseUserPermissions = viewMode === 'user' || !user?.is_superuser; // Don't show modules until initialization is complete to prevent flash - const mainModules = !modulesInitialized ? [] : (appModules + const mainModulesFiltered = !modulesInitialized ? [] : (appModules .find((cat) => cat.id === 'main') ?.modules.filter((m) => { if (!m.enabled) return false; @@ -44,6 +44,25 @@ export default function Sidebar() { return isModuleEnabled(m.id); }) || []); + // Sort modules based on moduleOrder (dashboard always first, then ordered features) + const mainModules = [...mainModulesFiltered].sort((a, b) => { + // Dashboard always comes first + if (a.id === 'dashboard') return -1; + if (b.id === 'dashboard') return 1; + + // Sort other modules by moduleOrder + const aIndex = moduleOrder.indexOf(a.id); + const bIndex = moduleOrder.indexOf(b.id); + + // If both are in the order array, sort by their position + if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex; + // If only one is in the order array, it comes first + if (aIndex !== -1) return -1; + if (bIndex !== -1) return 1; + // If neither is in the order array, maintain original order + return 0; + }); + const handleCollapseClick = () => { if (isMobileOpen) { closeMobileMenu(); diff --git a/frontend/src/contexts/ModulesContext.tsx b/frontend/src/contexts/ModulesContext.tsx index 2b50f90..0145bc7 100644 --- a/frontend/src/contexts/ModulesContext.tsx +++ b/frontend/src/contexts/ModulesContext.tsx @@ -18,12 +18,18 @@ export interface ModuleState { user: boolean; } +// Default order for modules +const DEFAULT_MODULE_ORDER: string[] = ['feature1', 'feature2', 'feature3']; + interface ModulesContextType { moduleStates: Record; + moduleOrder: string[]; isModuleEnabled: (moduleId: string) => boolean; isModuleEnabledForUser: (moduleId: string, userPermissions: UserPermissions | undefined, isSuperuser: boolean) => boolean; setModuleEnabled: (moduleId: ModuleId, type: 'admin' | 'user', enabled: boolean) => void; + setModuleOrder: (order: string[]) => void; saveModulesToBackend: () => Promise; + saveModuleOrder: (order: string[]) => Promise; isLoading: boolean; hasInitialized: boolean; } @@ -42,6 +48,7 @@ const getDefaultStates = (): Record => { export function ModulesProvider({ children }: { children: ReactNode }) { const { token } = useAuth(); const [moduleStates, setModuleStates] = useState>(getDefaultStates); + const [moduleOrder, setModuleOrderState] = useState(DEFAULT_MODULE_ORDER); const [isLoading, setIsLoading] = useState(true); const [hasInitialized, setHasInitialized] = useState(false); @@ -88,6 +95,24 @@ export function ModulesProvider({ children }: { children: ReactNode }) { }); setModuleStates(newStates); + + // Load module order + if (settings.modules_order) { + let order: string[]; + if (typeof settings.modules_order === 'string') { + try { + order = JSON.parse(settings.modules_order); + } catch { + order = DEFAULT_MODULE_ORDER; + } + } else if (Array.isArray(settings.modules_order)) { + order = settings.modules_order; + } else { + order = DEFAULT_MODULE_ORDER; + } + setModuleOrderState(order); + } + setHasInitialized(true); } catch (error) { console.error('Failed to load modules from backend:', error); @@ -112,6 +137,21 @@ export function ModulesProvider({ children }: { children: ReactNode }) { } }, [moduleStates]); + // Save module order to backend + const saveModuleOrder = useCallback(async (order: string[]) => { + try { + await settingsAPI.updateModules({ modules_order: order }); + } catch (error) { + console.error('Failed to save module order to backend:', error); + throw error; + } + }, []); + + // Set module order (local state only, call saveModuleOrder to persist) + const setModuleOrder = useCallback((order: string[]) => { + setModuleOrderState(order); + }, []); + // Load modules when token becomes available useEffect(() => { if (token) { @@ -185,10 +225,13 @@ export function ModulesProvider({ children }: { children: ReactNode }) { ('feature1'); + const { moduleStates, moduleOrder, setModuleEnabled, setModuleOrder, saveModulesToBackend, saveModuleOrder, hasInitialized, isLoading } = useModules(); + const [activeTab, setActiveTab] = useState('config'); const hasUserMadeChanges = useRef(false); const saveRef = useRef(saveModulesToBackend); - const [tooltip, setTooltip] = useState<{ text: string; left: number; visible: boolean }>({ - text: '', - left: 0, - visible: false, - }); - - const handleTabMouseEnter = (text: string, e: React.MouseEvent) => { - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - setTooltip({ - text, - left: rect.left + rect.width / 2, - visible: true, - }); - }; - - const handleTabMouseLeave = () => { - setTooltip((prev) => ({ ...prev, visible: false })); - }; + const [draggedItem, setDraggedItem] = useState(null); + const [localOrder, setLocalOrder] = useState([]); + const [hasOrderChanges, setHasOrderChanges] = useState(false); + // Sync local order with context order + useEffect(() => { + if (moduleOrder.length > 0) { + setLocalOrder(moduleOrder); + setHasOrderChanges(false); + } + }, [moduleOrder]); // Keep saveRef updated with latest function useEffect(() => { saveRef.current = saveModulesToBackend; @@ -118,11 +110,118 @@ export default function Features() { ); }; + // Drag and drop handlers for ordering + const handleDragStart = (e: React.DragEvent, moduleId: string) => { + setDraggedItem(moduleId); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', moduleId); + // Add dragging class after a small delay for visual feedback + setTimeout(() => { + (e.target as HTMLElement).classList.add('dragging'); + }, 0); + }; + + const handleDragEnd = (e: React.DragEvent) => { + setDraggedItem(null); + (e.target as HTMLElement).classList.remove('dragging'); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }; + + const handleDrop = (e: React.DragEvent, targetModuleId: string) => { + e.preventDefault(); + if (!draggedItem || draggedItem === targetModuleId) return; + + const newOrder = [...localOrder]; + const draggedIndex = newOrder.indexOf(draggedItem); + const targetIndex = newOrder.indexOf(targetModuleId); + + if (draggedIndex === -1 || targetIndex === -1) return; + + // Remove dragged item and insert at target position + newOrder.splice(draggedIndex, 1); + newOrder.splice(targetIndex, 0, draggedItem); + + setLocalOrder(newOrder); + setHasOrderChanges(true); + }; + + const handleApplyOrder = async () => { + try { + setModuleOrder(localOrder); + await saveModuleOrder(localOrder); + setHasOrderChanges(false); + } catch (error) { + console.error('Failed to save order:', error); + } + }; + + const getModuleInfo = (moduleId: string) => { + const module = TOGGLEABLE_MODULES.find(m => m.id === moduleId); + return module || { id: moduleId, icon: 'extension', defaultEnabled: true }; + }; + + const renderConfigTab = () => { + return ( +
+
+
+

{t.featuresPage?.orderSection || 'Ordine nella Sidebar'}

+
+
+ {localOrder.map((moduleId, index) => { + const moduleInfo = getModuleInfo(moduleId); + const moduleName = t.sidebar[moduleId as keyof typeof t.sidebar] || moduleId; + return ( +
handleDragStart(e, moduleId)} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, moduleId)} + > +
+ {index + 1} +
+
+ {moduleName} + {t.featuresPage?.orderDesc || 'Trascina per riordinare'} +
+
+ {moduleInfo.icon} +
+
+ drag_indicator +
+
+ ); + })} +
+ {hasOrderChanges && ( +
+ +
+ )} +
+
+ ); + }; + const renderTabContent = () => { if (!hasInitialized || isLoading) { return
Loading...
; } switch (activeTab) { + case 'config': + return renderConfigTab(); case 'feature1': return ( <> @@ -163,52 +262,37 @@ export default function Features() { return (
- {/* Tab Tooltip */} - {tooltip.visible && ( -
- {tooltip.text} -
- )} -
-
+
+
-
+
extension - {t.featuresPage.title} + {t.featuresPage.title}
-
+
- - + {(localOrder.length > 0 ? localOrder : ['feature1', 'feature2', 'feature3']).map((moduleId) => { + const moduleInfo = getModuleInfo(moduleId); + const moduleName = t.sidebar[moduleId as keyof typeof t.sidebar] || moduleId; + return ( + + ); + })}
diff --git a/frontend/src/styles/AdminPanel.css b/frontend/src/styles/AdminPanel.css index 8a11432..bbba2b5 100644 --- a/frontend/src/styles/AdminPanel.css +++ b/frontend/src/styles/AdminPanel.css @@ -2378,3 +2378,155 @@ [data-theme='dark'][data-accent='auto'] .users-modal .toggle-inline input:checked+.toggle-slider-sm::before { background: #111827; } + +/* =========================================== + ORDER CARDS - Feature Ordering (Theme Editor Style) + =========================================== */ + +/* Order Cards Container - Vertical Stack */ +.order-cards { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 600px; +} + +/* Order Card */ +.order-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 1.25rem; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: grab; + transition: all 0.25s ease; + user-select: none; +} + +.order-card:hover { + transform: translateX(4px); + border-color: var(--color-text-secondary); + box-shadow: var(--shadow-md); +} + +.order-card:active { + cursor: grabbing; +} + +.order-card.dragging { + opacity: 0.6; + transform: scale(1.02); + box-shadow: var(--shadow-lg); + border-color: var(--color-accent); + background: rgba(var(--color-accent-rgb), 0.05); +} + +/* Order Card Preview - Number Badge */ +.order-card-preview { + flex-shrink: 0; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-bg-elevated); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); +} + +.order-card-number { + font-size: 1.1rem; + font-weight: 700; + color: var(--color-accent); +} + +/* Order Card Info */ +.order-card-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.order-card-name { + font-weight: 600; + font-size: 1.05rem; + color: var(--color-text-primary); +} + +.order-card-desc { + font-size: 0.9rem; + color: var(--color-text-secondary); +} + +/* Order Card Icon */ +.order-card-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: rgba(var(--color-accent-rgb), 0.1); + border-radius: var(--radius-md); +} + +.order-card-icon .material-symbols-outlined { + font-size: 20px; + color: var(--color-accent); +} + +/* Order Card Handle */ +.order-card-handle { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem; + cursor: grab; +} + +.order-card-handle:active { + cursor: grabbing; +} + +.order-card-handle .material-symbols-outlined { + font-size: 24px; + color: var(--color-text-muted); + transition: color 0.2s ease; +} + +.order-card:hover .order-card-handle .material-symbols-outlined { + color: var(--color-text-secondary); +} + +/* Order Actions */ +.order-actions { + margin-top: 1.5rem; + display: flex; + gap: 0.75rem; +} + +.order-actions .btn-primary { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + font-size: 0.95rem; + font-weight: 500; +} + +.order-actions .btn-primary .material-symbols-outlined { + font-size: 20px; +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + .order-cards { + max-width: 100%; + } +} +