mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-16 03:41:43 +00:00
366 lines
11 KiB
JavaScript
366 lines
11 KiB
JavaScript
import {
|
|
closestCenter,
|
|
DndContext,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
arrayMove,
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Eye,
|
|
EyeOff,
|
|
GripVertical,
|
|
RotateCcw,
|
|
Save,
|
|
Settings as SettingsIcon,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
import { dashboardPreferencesAPI } from "../utils/api";
|
|
|
|
// Sortable Card Item Component
|
|
const SortableCardItem = ({ card, onToggle }) => {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({
|
|
id: card.cardId,
|
|
});
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.5 : 1,
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={`flex items-center justify-between p-3 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg ${
|
|
isDragging ? "shadow-lg" : "shadow-sm"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
{...attributes}
|
|
{...listeners}
|
|
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 cursor-grab active:cursor-grabbing"
|
|
>
|
|
<GripVertical className="h-4 w-4" />
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
|
{card.title}
|
|
{card.typeLabel ? (
|
|
<span className="ml-2 text-xs font-normal text-secondary-500 dark:text-secondary-400">
|
|
({card.typeLabel})
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => onToggle(card.cardId)}
|
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
|
|
card.enabled
|
|
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800"
|
|
: "bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600"
|
|
}`}
|
|
>
|
|
{card.enabled ? (
|
|
<>
|
|
<Eye className="h-3 w-3" />
|
|
Visible
|
|
</>
|
|
) : (
|
|
<>
|
|
<EyeOff className="h-3 w-3" />
|
|
Hidden
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
|
const [cards, setCards] = useState([]);
|
|
const [hasChanges, setHasChanges] = useState(false);
|
|
const queryClient = useQueryClient();
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|
}),
|
|
);
|
|
|
|
// Fetch user's dashboard preferences
|
|
const { data: preferences, isLoading } = useQuery({
|
|
queryKey: ["dashboardPreferences"],
|
|
queryFn: () => dashboardPreferencesAPI.get().then((res) => res.data),
|
|
enabled: isOpen,
|
|
});
|
|
|
|
// Fetch default card configuration
|
|
const { data: defaultCards } = useQuery({
|
|
queryKey: ["dashboardDefaultCards"],
|
|
queryFn: () =>
|
|
dashboardPreferencesAPI.getDefaults().then((res) => res.data),
|
|
enabled: isOpen,
|
|
});
|
|
|
|
// Update preferences mutation
|
|
const updatePreferencesMutation = useMutation({
|
|
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
|
|
onSuccess: (response) => {
|
|
// Optimistically update the query cache with the correct data structure
|
|
queryClient.setQueryData(
|
|
["dashboardPreferences"],
|
|
response.data.preferences,
|
|
);
|
|
// Also invalidate to ensure fresh data
|
|
queryClient.invalidateQueries(["dashboardPreferences"]);
|
|
setHasChanges(false);
|
|
onClose();
|
|
},
|
|
onError: (error) => {
|
|
console.error("Failed to update dashboard preferences:", error);
|
|
},
|
|
});
|
|
|
|
// Initialize cards when preferences or defaults are loaded
|
|
useEffect(() => {
|
|
if (preferences && defaultCards) {
|
|
// Normalize server preferences (snake_case -> camelCase)
|
|
const normalizedPreferences = preferences.map((p) => ({
|
|
cardId: p.cardId ?? p.card_id,
|
|
enabled: p.enabled,
|
|
order: p.order,
|
|
}));
|
|
|
|
const typeLabelFor = (cardId) => {
|
|
if (
|
|
[
|
|
"totalHosts",
|
|
"hostsNeedingUpdates",
|
|
"totalOutdatedPackages",
|
|
"securityUpdates",
|
|
"upToDateHosts",
|
|
"totalHostGroups",
|
|
"totalUsers",
|
|
"totalRepos",
|
|
].includes(cardId)
|
|
)
|
|
return "Top card";
|
|
if (cardId === "osDistribution") return "Pie chart";
|
|
if (cardId === "osDistributionBar") return "Bar chart";
|
|
if (cardId === "updateStatus") return "Pie chart";
|
|
if (cardId === "packagePriority") return "Pie chart";
|
|
if (cardId === "recentUsers") return "Table";
|
|
if (cardId === "recentCollection") return "Table";
|
|
if (cardId === "quickStats") return "Wide card";
|
|
return undefined;
|
|
};
|
|
|
|
// Merge user preferences with default cards
|
|
const mergedCards = defaultCards
|
|
.map((defaultCard) => {
|
|
const userPreference = normalizedPreferences.find(
|
|
(p) => p.cardId === defaultCard.cardId,
|
|
);
|
|
return {
|
|
...defaultCard,
|
|
enabled: userPreference
|
|
? userPreference.enabled
|
|
: defaultCard.enabled,
|
|
order: userPreference ? userPreference.order : defaultCard.order,
|
|
typeLabel: typeLabelFor(defaultCard.cardId),
|
|
};
|
|
})
|
|
.sort((a, b) => a.order - b.order);
|
|
|
|
setCards(mergedCards);
|
|
}
|
|
}, [preferences, defaultCards]);
|
|
|
|
const handleDragEnd = (event) => {
|
|
const { active, over } = event;
|
|
|
|
if (active.id !== over.id) {
|
|
setCards((items) => {
|
|
const oldIndex = items.findIndex((item) => item.cardId === active.id);
|
|
const newIndex = items.findIndex((item) => item.cardId === over.id);
|
|
|
|
const newItems = arrayMove(items, oldIndex, newIndex);
|
|
|
|
// Update order values
|
|
return newItems.map((item, index) => ({
|
|
...item,
|
|
order: index,
|
|
}));
|
|
});
|
|
setHasChanges(true);
|
|
}
|
|
};
|
|
|
|
const handleToggle = (cardId) => {
|
|
setCards((prevCards) =>
|
|
prevCards.map((card) =>
|
|
card.cardId === cardId ? { ...card, enabled: !card.enabled } : card,
|
|
),
|
|
);
|
|
setHasChanges(true);
|
|
};
|
|
|
|
const handleSave = () => {
|
|
const preferences = cards.map((card) => ({
|
|
cardId: card.cardId,
|
|
enabled: card.enabled,
|
|
order: card.order,
|
|
}));
|
|
|
|
updatePreferencesMutation.mutate(preferences);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
if (defaultCards) {
|
|
const resetCards = defaultCards.map((card) => ({
|
|
...card,
|
|
enabled: true,
|
|
order: card.order,
|
|
}));
|
|
setCards(resetCards);
|
|
setHasChanges(true);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
|
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
<button
|
|
type="button"
|
|
className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity cursor-default"
|
|
onClick={onClose}
|
|
aria-label="Close modal"
|
|
/>
|
|
|
|
<div className="inline-block align-bottom bg-white dark:bg-secondary-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
|
<div className="bg-white dark:bg-secondary-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<SettingsIcon className="h-5 w-5 text-primary-600" />
|
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
|
Dashboard Settings
|
|
</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
|
|
Customize your dashboard by reordering cards and toggling their
|
|
visibility. Drag cards to reorder them, and click the visibility
|
|
toggle to show/hide cards.
|
|
</p>
|
|
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
|
</div>
|
|
) : (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={cards.map((card) => card.cardId)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
{cards.map((card) => (
|
|
<SortableCardItem
|
|
key={card.cardId}
|
|
card={card}
|
|
onToggle={handleToggle}
|
|
/>
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
)}
|
|
</div>
|
|
|
|
<div className="bg-secondary-50 dark:bg-secondary-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={!hasChanges || updatePreferencesMutation.isPending}
|
|
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm ${
|
|
!hasChanges || updatePreferencesMutation.isPending
|
|
? "bg-secondary-400 cursor-not-allowed"
|
|
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
|
}`}
|
|
>
|
|
{updatePreferencesMutation.isPending ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
|
Saving...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="h-4 w-4 mr-2" />
|
|
Save Changes
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleReset}
|
|
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
|
>
|
|
<RotateCcw className="h-4 w-4 mr-2" />
|
|
Reset to Defaults
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DashboardSettingsModal;
|