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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user