Add module ordering feature with drag-and-drop

- Add modules_order setting in backend settings registry
- Update ModulesContext with moduleOrder state and save functions
- Create configuration tab in Features page with drag-and-drop reordering
- Make feature tabs dynamic based on order (updates in real-time)
- Sort sidebar modules based on saved order
- Add order-cards CSS with vertical layout
- Update API client to support string[] type for modules_order

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-15 21:25:43 +01:00
parent ba53e0eff0
commit 15f211493d
8 changed files with 384 additions and 68 deletions

View File

@@ -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)

View File

@@ -59,13 +59,13 @@ export const settingsAPI = {
return response.data;
},
getModules: async (): Promise<Record<string, boolean | string>> => {
const response = await api.get<Record<string, boolean | string>>('/settings/modules');
getModules: async (): Promise<Record<string, boolean | string | string[]>> => {
const response = await api.get<Record<string, boolean | string | string[]>>('/settings/modules');
return response.data;
},
updateModules: async (data: Record<string, boolean>): Promise<Record<string, boolean>> => {
const response = await api.put<Record<string, boolean>>('/settings/modules', data);
updateModules: async (data: Record<string, boolean | string[]>): Promise<Record<string, boolean | string[]>> => {
const response = await api.put<Record<string, boolean | string[]>>('/settings/modules', data);
return response.data;
},

View File

@@ -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();

View File

@@ -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<ModuleId, ModuleState>;
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<void>;
saveModuleOrder: (order: string[]) => Promise<void>;
isLoading: boolean;
hasInitialized: boolean;
}
@@ -42,6 +48,7 @@ const getDefaultStates = (): Record<ModuleId, ModuleState> => {
export function ModulesProvider({ children }: { children: ReactNode }) {
const { token } = useAuth();
const [moduleStates, setModuleStates] = useState<Record<ModuleId, ModuleState>>(getDefaultStates);
const [moduleOrder, setModuleOrderState] = useState<string[]>(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 }) {
<ModulesContext.Provider
value={{
moduleStates,
moduleOrder,
isModuleEnabled,
isModuleEnabledForUser,
setModuleEnabled,
setModuleOrder,
saveModulesToBackend,
saveModuleOrder,
isLoading,
hasInitialized,
}}

View File

@@ -55,7 +55,11 @@
},
"featuresPage": {
"title": "Features",
"subtitle": "Configure application features"
"subtitle": "Configure application features",
"configTab": "Configuration",
"orderSection": "Sidebar Order",
"orderDesc": "Drag to reorder features in the sidebar",
"applyOrder": "Apply"
},
"sourcesPage": {
"title": "Sources",

View File

@@ -55,7 +55,11 @@
},
"featuresPage": {
"title": "Funzionalità",
"subtitle": "Configura le funzionalità dell'applicazione"
"subtitle": "Configura le funzionalità dell'applicazione",
"configTab": "Configurazione",
"orderSection": "Ordine nella Sidebar",
"orderDesc": "Trascina per riordinare le funzioni nella barra laterale",
"applyOrder": "Applica"
},
"sourcesPage": {
"title": "Sorgenti",

View File

@@ -2,40 +2,32 @@ import { useState, useEffect, useRef } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { useTranslation } from '../../contexts/LanguageContext';
import { useSidebar } from '../../contexts/SidebarContext';
import { useModules } from '../../contexts/ModulesContext';
import { useModules, TOGGLEABLE_MODULES } from '../../contexts/ModulesContext';
import type { ModuleId } from '../../contexts/ModulesContext';
import Feature1Tab from '../../components/admin/Feature1Tab';
import '../../styles/AdminPanel.css';
type TabId = 'feature1' | 'feature2' | 'feature3';
type TabId = 'config' | 'feature1' | 'feature2' | 'feature3';
export default function Features() {
const { user: currentUser } = useAuth();
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
const { moduleStates, setModuleEnabled, saveModulesToBackend, hasInitialized, isLoading } = useModules();
const [activeTab, setActiveTab] = useState<TabId>('feature1');
const { moduleStates, moduleOrder, setModuleEnabled, setModuleOrder, saveModulesToBackend, saveModuleOrder, hasInitialized, isLoading } = useModules();
const [activeTab, setActiveTab] = useState<TabId>('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<string | null>(null);
const [localOrder, setLocalOrder] = useState<string[]>([]);
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 (
<div className="theme-tab-content">
<div className="theme-section">
<div className="section-header">
<h3 className="section-title">{t.featuresPage?.orderSection || 'Ordine nella Sidebar'}</h3>
</div>
<div className="order-cards">
{localOrder.map((moduleId, index) => {
const moduleInfo = getModuleInfo(moduleId);
const moduleName = t.sidebar[moduleId as keyof typeof t.sidebar] || moduleId;
return (
<div
key={moduleId}
className={`order-card ${draggedItem === moduleId ? 'dragging' : ''}`}
draggable
onDragStart={(e) => handleDragStart(e, moduleId)}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, moduleId)}
>
<div className="order-card-preview">
<span className="order-card-number">{index + 1}</span>
</div>
<div className="order-card-info">
<span className="order-card-name">{moduleName}</span>
<span className="order-card-desc">{t.featuresPage?.orderDesc || 'Trascina per riordinare'}</span>
</div>
<div className="order-card-icon">
<span className="material-symbols-outlined">{moduleInfo.icon}</span>
</div>
<div className="order-card-handle">
<span className="material-symbols-outlined">drag_indicator</span>
</div>
</div>
);
})}
</div>
{hasOrderChanges && (
<div className="order-actions">
<button className="btn-primary" onClick={handleApplyOrder}>
<span className="material-symbols-outlined">check</span>
{t.featuresPage?.applyOrder || 'Applica'}
</button>
</div>
)}
</div>
</div>
);
};
const renderTabContent = () => {
if (!hasInitialized || isLoading) {
return <div className="loading">Loading...</div>;
}
switch (activeTab) {
case 'config':
return renderConfigTab();
case 'feature1':
return (
<>
@@ -163,52 +262,37 @@ export default function Features() {
return (
<main className="main-content admin-panel-root">
{/* Tab Tooltip */}
{tooltip.visible && (
<div
className="admin-tab-tooltip"
style={{ left: tooltip.left }}
>
{tooltip.text}
</div>
)}
<div className="admin-tabs-container">
<div className="admin-tabs-slider">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="admin-title-section">
<div className="page-title-section">
<span className="material-symbols-outlined">extension</span>
<span className="admin-title-text">{t.featuresPage.title}</span>
<span className="page-title-text">{t.featuresPage.title}</span>
</div>
<div className="admin-tabs-divider"></div>
<div className="page-tabs-divider"></div>
<button
className={`admin-tab-btn ${activeTab === 'feature1' ? 'active' : ''}`}
onClick={() => setActiveTab('feature1')}
onMouseEnter={(e) => handleTabMouseEnter(t.sidebar.feature1, e)}
onMouseLeave={handleTabMouseLeave}
className={`page-tab-btn ${activeTab === 'config' ? 'active' : ''}`}
onClick={() => setActiveTab('config')}
>
<span className="material-symbols-outlined">playlist_play</span>
<span>{t.sidebar.feature1}</span>
</button>
<button
className={`admin-tab-btn ${activeTab === 'feature2' ? 'active' : ''}`}
onClick={() => setActiveTab('feature2')}
onMouseEnter={(e) => handleTabMouseEnter(t.sidebar.feature2, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">download</span>
<span>{t.sidebar.feature2}</span>
</button>
<button
className={`admin-tab-btn ${activeTab === 'feature3' ? 'active' : ''}`}
onClick={() => setActiveTab('feature3')}
onMouseEnter={(e) => handleTabMouseEnter(t.sidebar.feature3, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">cast</span>
<span>{t.sidebar.feature3}</span>
<span className="material-symbols-outlined">tune</span>
<span>{t.featuresPage?.configTab || 'Configurazione'}</span>
</button>
{(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 (
<button
key={moduleId}
className={`page-tab-btn ${activeTab === moduleId ? 'active' : ''}`}
onClick={() => setActiveTab(moduleId as TabId)}
>
<span className="material-symbols-outlined">{moduleInfo.icon}</span>
<span>{moduleName}</span>
</button>
);
})}
</div>
</div>

View File

@@ -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%;
}
}