Add ability to reorder apps on the home screen

Implements drag-and-drop functionality for reordering apps using @dnd-kit, adds a new `userAppOrdersTable` to the database schema to store user-specific app order preferences, and introduces a new API endpoint `/api/me/app-order` for updating these preferences.
This commit is contained in:
Riyadh
2026-04-20 12:09:14 +00:00
parent c46115ea2d
commit 8354156099
10 changed files with 381 additions and 46 deletions
+80 -11
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import {
@@ -10,13 +10,31 @@ import {
getListNotificationsQueryKey,
useLogout,
useUpdateLanguage,
useUpdateMyAppOrder,
getGetMeQueryKey,
type App,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import { Bell, LogOut, Globe } from "lucide-react";
import * as LucideIcons from "lucide-react";
import i18n from "@/i18n";
import {
DndContext,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
rectSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
function gradientForColor(color: string): string {
const c = (color ?? "").toLowerCase();
@@ -51,6 +69,22 @@ function AppIcon({ app, onClick }: { app: { nameAr: string; nameEn: string; icon
);
}
function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: app.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
zIndex: isDragging ? 20 : "auto",
touchAction: "none",
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<AppIcon app={app} onClick={onClick} />
</div>
);
}
export default function HomePage() {
const { t, i18n: i18nInstance } = useTranslation();
const [, setLocation] = useLocation();
@@ -60,6 +94,37 @@ export default function HomePage() {
const lang = i18nInstance.language;
const { data: apps } = useListApps({ query: { queryKey: getListAppsQueryKey() } });
const updateOrder = useUpdateMyAppOrder();
const [orderedApps, setOrderedApps] = useState<App[] | null>(null);
useEffect(() => {
if (apps && !updateOrder.isPending) setOrderedApps(apps);
}, [apps, updateOrder.isPending]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !orderedApps) return;
const oldIndex = orderedApps.findIndex((a) => a.id === active.id);
const newIndex = orderedApps.findIndex((a) => a.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
const next = arrayMove(orderedApps, oldIndex, newIndex);
setOrderedApps(next);
updateOrder.mutate(
{ data: { order: next.map((a) => a.id) } },
{
onError: () => {
queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() });
},
},
);
};
const sortableIds = useMemo(() => (orderedApps ?? []).map((a) => a.id), [orderedApps]);
const { data: stats } = useGetHomeStats({ query: { queryKey: getGetHomeStatsQueryKey() } });
const { data: notifications } = useListNotifications({ query: { queryKey: getListNotificationsQueryKey() } });
@@ -108,7 +173,7 @@ export default function HomePage() {
);
};
const dockApps = apps?.filter((a) => ["services", "chat", "notifications", "admin"].includes(a.slug)) ?? [];
const dockApps = (orderedApps ?? apps)?.filter((a) => ["services", "chat", "notifications", "admin"].includes(a.slug)) ?? [];
return (
<div className="min-h-screen os-bg flex flex-col overflow-hidden relative">
@@ -189,15 +254,19 @@ export default function HomePage() {
{/* Apps Grid */}
<div className="mb-4">
<h3 className="text-sm font-medium text-muted-foreground mb-4">{t("home.myApps")}</h3>
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-4">
{apps?.map((app) => (
<AppIcon
key={app.id}
app={app}
onClick={() => setLocation(app.route)}
/>
))}
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableIds} strategy={rectSortingStrategy}>
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-4">
{orderedApps?.map((app) => (
<SortableAppIcon
key={app.id}
app={app}
onClick={() => setLocation(app.route)}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
</div>