2026-04-20 12:09:14 +00:00
|
|
|
import { useState, useEffect, useMemo } from "react";
|
2026-04-20 09:20:50 +00:00
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
import { useLocation } from "wouter";
|
|
|
|
|
import {
|
|
|
|
|
useListApps,
|
|
|
|
|
getListAppsQueryKey,
|
|
|
|
|
useGetHomeStats,
|
|
|
|
|
getGetHomeStatsQueryKey,
|
|
|
|
|
useListNotifications,
|
|
|
|
|
getListNotificationsQueryKey,
|
|
|
|
|
useLogout,
|
2026-04-20 09:34:00 +00:00
|
|
|
useUpdateLanguage,
|
2026-04-20 12:09:14 +00:00
|
|
|
useUpdateMyAppOrder,
|
2026-04-20 09:34:00 +00:00
|
|
|
getGetMeQueryKey,
|
2026-04-20 12:09:14 +00:00
|
|
|
type App,
|
2026-04-20 09:20:50 +00:00
|
|
|
} 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";
|
2026-04-20 12:09:14 +00:00
|
|
|
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";
|
2026-04-20 09:20:50 +00:00
|
|
|
|
2026-04-20 10:25:54 +00:00
|
|
|
function gradientForColor(color: string): string {
|
|
|
|
|
const c = (color ?? "").toLowerCase();
|
|
|
|
|
if (c.includes("a855f7") || c.includes("8b5cf6") || c.includes("purple") || c.includes("violet")) return "icon-tile-purple";
|
|
|
|
|
if (c.includes("10b981") || c.includes("22c55e") || c.includes("green") || c.includes("emerald")) return "icon-tile-green";
|
|
|
|
|
if (c.includes("f59e0b") || c.includes("f97316") || c.includes("orange") || c.includes("amber")) return "icon-tile-orange";
|
|
|
|
|
if (c.includes("ec4899") || c.includes("pink") || c.includes("rose")) return "icon-tile-pink";
|
|
|
|
|
if (c.includes("14b8a6") || c.includes("06b6d4") || c.includes("teal") || c.includes("cyan")) return "icon-tile-teal";
|
|
|
|
|
return "icon-tile-blue";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 09:20:50 +00:00
|
|
|
function AppIcon({ app, onClick }: { app: { nameAr: string; nameEn: string; iconName: string; color: string; route: string }; onClick: () => void }) {
|
|
|
|
|
const { i18n: i18nInstance } = useTranslation();
|
|
|
|
|
const lang = i18nInstance.language;
|
|
|
|
|
const name = lang === "ar" ? app.nameAr : app.nameEn;
|
|
|
|
|
|
|
|
|
|
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number; color?: string }>>)[app.iconName]
|
|
|
|
|
?? LucideIcons.Grid2X2;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
className="flex flex-col items-center gap-2 group"
|
|
|
|
|
>
|
2026-04-20 10:25:54 +00:00
|
|
|
<div className={`icon-tile w-16 h-16 ${gradientForColor(app.color)} group-hover:scale-105`}>
|
|
|
|
|
<Icon size={28} color="#FFFFFF" />
|
2026-04-20 09:20:50 +00:00
|
|
|
</div>
|
|
|
|
|
<span className="text-xs text-foreground/80 font-medium text-center leading-tight max-w-16 truncate">
|
|
|
|
|
{name}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 12:09:14 +00:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 09:20:50 +00:00
|
|
|
export default function HomePage() {
|
|
|
|
|
const { t, i18n: i18nInstance } = useTranslation();
|
|
|
|
|
const [, setLocation] = useLocation();
|
|
|
|
|
const { user } = useAuth();
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
const [time, setTime] = useState(new Date());
|
|
|
|
|
const lang = i18nInstance.language;
|
|
|
|
|
|
|
|
|
|
const { data: apps } = useListApps({ query: { queryKey: getListAppsQueryKey() } });
|
2026-04-20 12:09:14 +00:00
|
|
|
const updateOrder = useUpdateMyAppOrder();
|
|
|
|
|
const [orderedApps, setOrderedApps] = useState<App[] | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-20 12:15:01 +00:00
|
|
|
if (!apps) return;
|
|
|
|
|
setOrderedApps((prev) => {
|
|
|
|
|
if (!prev) return apps;
|
|
|
|
|
const byId = new Map(apps.map((a) => [a.id, a]));
|
|
|
|
|
const kept = prev
|
|
|
|
|
.map((a) => byId.get(a.id))
|
|
|
|
|
.filter((a): a is App => a !== undefined);
|
|
|
|
|
const keptIds = new Set(kept.map((a) => a.id));
|
|
|
|
|
const added = apps.filter((a) => !keptIds.has(a.id));
|
|
|
|
|
return [...kept, ...added];
|
|
|
|
|
});
|
2026-04-20 12:13:22 +00:00
|
|
|
}, [apps]);
|
2026-04-20 12:09:14 +00:00
|
|
|
|
|
|
|
|
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;
|
2026-04-20 12:13:22 +00:00
|
|
|
const previous = orderedApps;
|
2026-04-20 12:09:14 +00:00
|
|
|
const next = arrayMove(orderedApps, oldIndex, newIndex);
|
|
|
|
|
setOrderedApps(next);
|
|
|
|
|
updateOrder.mutate(
|
|
|
|
|
{ data: { order: next.map((a) => a.id) } },
|
|
|
|
|
{
|
2026-04-20 12:13:22 +00:00
|
|
|
onSuccess: (data) => {
|
|
|
|
|
setOrderedApps(data);
|
|
|
|
|
queryClient.setQueryData(getListAppsQueryKey(), data);
|
|
|
|
|
},
|
2026-04-20 12:09:14 +00:00
|
|
|
onError: () => {
|
2026-04-20 12:13:22 +00:00
|
|
|
setOrderedApps(previous);
|
2026-04-20 12:09:14 +00:00
|
|
|
queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() });
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sortableIds = useMemo(() => (orderedApps ?? []).map((a) => a.id), [orderedApps]);
|
2026-04-20 09:20:50 +00:00
|
|
|
const { data: stats } = useGetHomeStats({ query: { queryKey: getGetHomeStatsQueryKey() } });
|
|
|
|
|
const { data: notifications } = useListNotifications({ query: { queryKey: getListNotificationsQueryKey() } });
|
|
|
|
|
|
|
|
|
|
const logout = useLogout();
|
2026-04-20 09:34:00 +00:00
|
|
|
const updateLanguage = useUpdateLanguage();
|
2026-04-20 09:20:50 +00:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const timer = setInterval(() => setTime(new Date()), 1000);
|
|
|
|
|
return () => clearInterval(timer);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-20 11:44:10 +00:00
|
|
|
const dateLocale = lang === "ar" ? "ar-SA" : "en-US";
|
|
|
|
|
const dayName = new Intl.DateTimeFormat(dateLocale, { weekday: "long" }).format(time);
|
|
|
|
|
const hijriDate = new Intl.DateTimeFormat(
|
|
|
|
|
lang === "ar" ? "ar-SA-u-ca-islamic-umalqura" : "en-US-u-ca-islamic-umalqura",
|
|
|
|
|
{ day: "numeric", month: "long", year: "numeric" },
|
|
|
|
|
).format(time);
|
|
|
|
|
const gregorianDate = new Intl.DateTimeFormat(
|
|
|
|
|
lang === "ar" ? "ar-SA-u-ca-gregory" : "en-US",
|
|
|
|
|
{ day: "numeric", month: "long", year: "numeric" },
|
|
|
|
|
).format(time);
|
|
|
|
|
|
2026-04-20 09:20:50 +00:00
|
|
|
const unreadCount = notifications?.filter((n) => !n.isRead).length ?? stats?.unreadNotifications ?? 0;
|
|
|
|
|
|
|
|
|
|
const displayName = lang === "ar"
|
|
|
|
|
? (user?.displayNameAr ?? user?.username)
|
|
|
|
|
: (user?.displayNameEn ?? user?.username);
|
|
|
|
|
|
2026-04-20 10:49:57 +00:00
|
|
|
const isAdmin = user?.roles?.includes("admin") ?? false;
|
2026-04-20 09:20:50 +00:00
|
|
|
|
|
|
|
|
const handleLogout = () => {
|
|
|
|
|
logout.mutate(undefined, {
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.clear();
|
|
|
|
|
setLocation("/login");
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toggleLanguage = () => {
|
|
|
|
|
const newLang = lang === "ar" ? "en" : "ar";
|
|
|
|
|
i18n.changeLanguage(newLang);
|
2026-04-20 09:34:00 +00:00
|
|
|
updateLanguage.mutate(
|
|
|
|
|
{ data: { language: newLang } },
|
|
|
|
|
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }) },
|
|
|
|
|
);
|
2026-04-20 09:20:50 +00:00
|
|
|
};
|
|
|
|
|
|
2026-04-20 12:09:14 +00:00
|
|
|
const dockApps = (orderedApps ?? apps)?.filter((a) => ["services", "chat", "notifications", "admin"].includes(a.slug)) ?? [];
|
2026-04-20 09:20:50 +00:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen os-bg flex flex-col overflow-hidden relative">
|
|
|
|
|
{/* Status Bar */}
|
2026-04-20 11:44:10 +00:00
|
|
|
<div className="topbar-glass px-4 py-3 flex items-center justify-between gap-2 z-10 sticky top-0">
|
|
|
|
|
<div className="flex items-center gap-3 min-w-0 shrink">
|
|
|
|
|
<span className="text-foreground font-medium text-sm truncate max-w-24">{displayName}</span>
|
2026-04-20 09:20:50 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-04-20 11:44:10 +00:00
|
|
|
<div className="flex flex-col items-center text-foreground leading-tight min-w-0 px-2">
|
|
|
|
|
<div className="font-mono text-sm font-bold">
|
|
|
|
|
{time.toLocaleTimeString(dateLocale, {
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-[10px] text-muted-foreground flex items-center gap-1 mt-0.5 max-w-full truncate">
|
|
|
|
|
<span className="truncate">{dayName}</span>
|
|
|
|
|
<span aria-hidden="true">·</span>
|
|
|
|
|
<span className="truncate">{hijriDate}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-[10px] text-muted-foreground max-w-full truncate">
|
|
|
|
|
{gregorianDate}
|
|
|
|
|
</div>
|
2026-04-20 09:20:50 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={toggleLanguage}
|
2026-04-20 10:25:54 +00:00
|
|
|
className="text-muted-foreground hover:text-foreground transition-colors text-xs font-medium px-2 py-1 rounded-lg hover:bg-slate-100"
|
2026-04-20 09:20:50 +00:00
|
|
|
>
|
|
|
|
|
{t("common.language")}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setLocation("/notifications")}
|
2026-04-20 10:25:54 +00:00
|
|
|
className="relative p-1.5 rounded-lg hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
|
2026-04-20 09:20:50 +00:00
|
|
|
>
|
|
|
|
|
<Bell size={18} />
|
|
|
|
|
{unreadCount > 0 && (
|
|
|
|
|
<span className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground text-xs rounded-full w-4 h-4 flex items-center justify-center font-bold">
|
|
|
|
|
{unreadCount > 9 ? "9+" : unreadCount}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleLogout}
|
2026-04-20 10:25:54 +00:00
|
|
|
className="p-1.5 rounded-lg hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
|
2026-04-20 09:20:50 +00:00
|
|
|
>
|
|
|
|
|
<LogOut size={18} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Main Content */}
|
|
|
|
|
<div className="flex-1 p-6 pb-28 overflow-y-auto">
|
2026-04-20 10:49:57 +00:00
|
|
|
{/* Stats Row — admin only */}
|
|
|
|
|
{isAdmin && stats && (
|
2026-04-20 09:20:50 +00:00
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
|
|
|
|
|
<div className="glass-panel rounded-2xl p-3 text-center">
|
|
|
|
|
<div className="text-2xl font-bold text-primary">{stats.totalApps}</div>
|
|
|
|
|
<div className="text-xs text-muted-foreground mt-1">{t("home.myApps")}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="glass-panel rounded-2xl p-3 text-center">
|
|
|
|
|
<div className="text-2xl font-bold text-secondary">{stats.totalServices}</div>
|
|
|
|
|
<div className="text-xs text-muted-foreground mt-1">{t("nav.services")}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="glass-panel rounded-2xl p-3 text-center">
|
|
|
|
|
<div className="text-2xl font-bold text-accent">{stats.unreadMessages}</div>
|
|
|
|
|
<div className="text-xs text-muted-foreground mt-1">{t("nav.chat")}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="glass-panel rounded-2xl p-3 text-center">
|
|
|
|
|
<div className="text-2xl font-bold text-destructive">{stats.unreadNotifications}</div>
|
|
|
|
|
<div className="text-xs text-muted-foreground mt-1">{t("nav.notifications")}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Apps Grid */}
|
|
|
|
|
<div className="mb-4">
|
|
|
|
|
<h3 className="text-sm font-medium text-muted-foreground mb-4">{t("home.myApps")}</h3>
|
2026-04-20 12:09:14 +00:00
|
|
|
<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>
|
2026-04-20 09:20:50 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Bottom Dock */}
|
|
|
|
|
<div className="fixed bottom-0 left-0 right-0 p-4 z-10">
|
2026-04-20 10:25:54 +00:00
|
|
|
<div className="dock-glass rounded-3xl px-6 py-3 flex items-center justify-around max-w-sm mx-auto">
|
2026-04-20 09:20:50 +00:00
|
|
|
{dockApps.slice(0, 4).map((app) => {
|
|
|
|
|
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number; color?: string }>>)[app.iconName]
|
|
|
|
|
?? LucideIcons.Grid2X2;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={app.id}
|
|
|
|
|
onClick={() => setLocation(app.route)}
|
|
|
|
|
className="flex flex-col items-center gap-1 group"
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className="w-12 h-12 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform"
|
|
|
|
|
style={{ backgroundColor: `${app.color}25` }}
|
|
|
|
|
>
|
|
|
|
|
<Icon size={22} color={app.color} />
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|