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 15:16:19 +00:00
|
|
|
logAppOpen,
|
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";
|
2026-04-20 15:05:11 +00:00
|
|
|
import {
|
|
|
|
|
Bell,
|
|
|
|
|
LogOut,
|
|
|
|
|
Globe,
|
|
|
|
|
Grid2X2,
|
|
|
|
|
Coffee,
|
|
|
|
|
MessageSquare,
|
|
|
|
|
BellRing,
|
|
|
|
|
Sparkles,
|
|
|
|
|
} from "lucide-react";
|
2026-04-20 09:20:50 +00:00
|
|
|
import * as LucideIcons from "lucide-react";
|
2026-04-20 15:05:11 +00:00
|
|
|
import type { LucideIcon } from "lucide-react";
|
2026-04-20 09:20:50 +00:00
|
|
|
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 15:05:11 +00:00
|
|
|
import {
|
|
|
|
|
formatDate,
|
|
|
|
|
formatWeekday,
|
|
|
|
|
formatNumber,
|
|
|
|
|
greetingKey,
|
|
|
|
|
} from "@/lib/i18n-format";
|
2026-04-20 16:40:49 +00:00
|
|
|
import { Clock, useNow } from "@/components/clock";
|
|
|
|
|
import { ClockStylePicker } from "@/components/clock-style-picker";
|
2026-04-20 15:05:11 +00:00
|
|
|
|
|
|
|
|
type IconName = keyof typeof LucideIcons;
|
|
|
|
|
function isIconName(x: string): x is IconName {
|
|
|
|
|
return x in LucideIcons;
|
|
|
|
|
}
|
|
|
|
|
function resolveIcon(name: string): LucideIcon {
|
|
|
|
|
if (isIconName(name)) {
|
|
|
|
|
const Candidate = LucideIcons[name] as unknown;
|
|
|
|
|
if (typeof Candidate === "function" || typeof Candidate === "object") {
|
|
|
|
|
return Candidate as LucideIcon;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return LucideIcons.Grid2X2;
|
|
|
|
|
}
|
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 15:16:19 +00:00
|
|
|
function AppIconContent({ app }: { app: { nameAr: string; nameEn: string; iconName: string; color: string } }) {
|
2026-04-20 09:20:50 +00:00
|
|
|
const { i18n: i18nInstance } = useTranslation();
|
|
|
|
|
const lang = i18nInstance.language;
|
|
|
|
|
const name = lang === "ar" ? app.nameAr : app.nameEn;
|
|
|
|
|
|
2026-04-20 15:05:11 +00:00
|
|
|
const Icon = resolveIcon(app.iconName);
|
2026-04-20 09:20:50 +00:00
|
|
|
|
|
|
|
|
return (
|
2026-04-20 15:16:19 +00:00
|
|
|
<>
|
2026-04-20 15:05:11 +00:00
|
|
|
<div className={`icon-tile w-[68px] h-[68px] sm:w-[72px] sm:h-[72px] ${gradientForColor(app.color)} group-hover:scale-105 group-active:scale-95`}>
|
|
|
|
|
<Icon size={30} color="#FFFFFF" />
|
2026-04-20 09:20:50 +00:00
|
|
|
</div>
|
2026-04-20 15:05:11 +00:00
|
|
|
<span className="text-[11px] text-foreground/85 font-medium text-center leading-tight max-w-[78px] truncate">
|
2026-04-20 09:20:50 +00:00
|
|
|
{name}
|
|
|
|
|
</span>
|
2026-04-20 15:16:19 +00:00
|
|
|
</>
|
2026-04-20 09:20:50 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
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 (
|
2026-04-20 15:16:19 +00:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
ref={setNodeRef}
|
|
|
|
|
style={style}
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
className="flex flex-col items-center gap-2 group"
|
|
|
|
|
{...attributes}
|
|
|
|
|
{...listeners}
|
|
|
|
|
>
|
|
|
|
|
<AppIconContent app={app} />
|
|
|
|
|
</button>
|
2026-04-20 12:09:14 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 15:05:11 +00:00
|
|
|
type StatCardProps = {
|
|
|
|
|
label: string;
|
|
|
|
|
value: string;
|
|
|
|
|
Icon: typeof Grid2X2;
|
|
|
|
|
iconClass: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function StatCard({ label, value, Icon, iconClass }: StatCardProps) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="glass-panel rounded-2xl p-3 flex items-center gap-3">
|
|
|
|
|
<div className={`shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${iconClass}`}>
|
|
|
|
|
<Icon size={18} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<div className="text-xl font-bold text-foreground leading-none tabular-nums">{value}</div>
|
|
|
|
|
<div className="text-[11px] text-muted-foreground mt-1 truncate">{label}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</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();
|
2026-04-20 16:40:49 +00:00
|
|
|
const time = useNow();
|
2026-04-20 09:20:50 +00:00
|
|
|
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
|
|
|
|
2026-04-20 15:16:19 +00:00
|
|
|
const openApp = (app: App) => {
|
|
|
|
|
// Fire-and-forget: keepalive ensures the request survives any
|
|
|
|
|
// imminent navigation triggered by setLocation below.
|
|
|
|
|
logAppOpen(app.id, { keepalive: true }).catch(() => {
|
|
|
|
|
// Best-effort tracking; ignore failures so navigation isn't blocked.
|
|
|
|
|
});
|
|
|
|
|
setLocation(app.route);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-20 15:05:11 +00:00
|
|
|
const dayName = formatWeekday(time, lang);
|
|
|
|
|
const gregorianDate = formatDate(time, lang);
|
2026-04-20 11:44:10 +00:00
|
|
|
|
2026-04-20 09:20:50 +00:00
|
|
|
const unreadCount = notifications?.filter((n) => !n.isRead).length ?? stats?.unreadNotifications ?? 0;
|
|
|
|
|
|
|
|
|
|
const displayName = lang === "ar"
|
2026-04-20 15:05:11 +00:00
|
|
|
? (user?.displayNameAr ?? user?.username ?? "")
|
|
|
|
|
: (user?.displayNameEn ?? user?.username ?? "");
|
2026-04-20 09:20:50 +00:00
|
|
|
|
2026-04-20 10:49:57 +00:00
|
|
|
const isAdmin = user?.roles?.includes("admin") ?? false;
|
2026-04-20 09:20:50 +00:00
|
|
|
|
2026-04-20 15:05:11 +00:00
|
|
|
const greeting = t(`home.greeting.${greetingKey(time)}`, { name: displayName });
|
|
|
|
|
|
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
|
|
|
|
2026-04-20 15:05:11 +00:00
|
|
|
const initials = (displayName || "?").trim().slice(0, 1).toUpperCase();
|
|
|
|
|
|
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 15:05:11 +00:00
|
|
|
<div className="topbar-glass px-4 sm:px-6 py-3 flex items-center justify-between gap-3 z-10 sticky top-0">
|
|
|
|
|
{/* Identity */}
|
|
|
|
|
<div className="flex items-center gap-2.5 min-w-0 shrink basis-0 flex-1">
|
|
|
|
|
<div className="w-9 h-9 rounded-full bg-primary/15 text-primary flex items-center justify-center font-bold text-sm shrink-0">
|
|
|
|
|
{initials}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="min-w-0 hidden sm:block">
|
|
|
|
|
<div className="text-foreground font-semibold text-sm truncate leading-tight">{displayName}</div>
|
|
|
|
|
{isAdmin && (
|
|
|
|
|
<div className="text-[10px] text-muted-foreground leading-tight">Admin</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-04-20 09:20:50 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-04-20 15:05:11 +00:00
|
|
|
{/* Clock + dates */}
|
2026-04-20 16:40:49 +00:00
|
|
|
<div className="px-2 shrink-0">
|
|
|
|
|
<Clock style={user?.clockStyle ?? null} time={time} lang={lang} />
|
2026-04-20 09:20:50 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-04-20 15:05:11 +00:00
|
|
|
{/* Controls */}
|
|
|
|
|
<div className="flex items-center gap-1 shrink basis-0 flex-1 justify-end">
|
2026-04-20 16:40:49 +00:00
|
|
|
<ClockStylePicker currentStyle={user?.clockStyle ?? null} />
|
2026-04-20 09:20:50 +00:00
|
|
|
<button
|
|
|
|
|
onClick={toggleLanguage}
|
2026-04-20 15:05:11 +00:00
|
|
|
aria-label="Toggle language"
|
|
|
|
|
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 rounded-lg hover:bg-foreground/5"
|
2026-04-20 09:20:50 +00:00
|
|
|
>
|
2026-04-20 15:05:11 +00:00
|
|
|
<Globe size={18} />
|
2026-04-20 09:20:50 +00:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setLocation("/notifications")}
|
2026-04-20 15:05:11 +00:00
|
|
|
className="relative p-1.5 rounded-lg hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
|
2026-04-20 09:20:50 +00:00
|
|
|
>
|
|
|
|
|
<Bell size={18} />
|
|
|
|
|
{unreadCount > 0 && (
|
2026-04-20 15:05:11 +00:00
|
|
|
<span className="absolute -top-0.5 -right-0.5 bg-destructive text-destructive-foreground text-[10px] rounded-full min-w-[16px] h-4 px-1 flex items-center justify-center font-bold tabular-nums">
|
2026-04-20 09:20:50 +00:00
|
|
|
{unreadCount > 9 ? "9+" : unreadCount}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleLogout}
|
2026-04-20 15:05:11 +00:00
|
|
|
className="p-1.5 rounded-lg hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
|
2026-04-20 09:20:50 +00:00
|
|
|
>
|
|
|
|
|
<LogOut size={18} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Main Content */}
|
2026-04-20 15:05:11 +00:00
|
|
|
<div className="flex-1 px-5 sm:px-8 pt-6 pb-32 overflow-y-auto">
|
|
|
|
|
{/* Greeting */}
|
|
|
|
|
<div className="mb-6 max-w-3xl">
|
|
|
|
|
<h1 className="text-2xl sm:text-3xl font-bold text-foreground tracking-tight">
|
|
|
|
|
{greeting}
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
|
|
|
{dayName} · <span className="tabular-nums">{gregorianDate}</span>
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-20 10:49:57 +00:00
|
|
|
{/* Stats Row — admin only */}
|
|
|
|
|
{isAdmin && stats && (
|
2026-04-20 15:05:11 +00:00
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8 max-w-4xl">
|
|
|
|
|
<StatCard
|
|
|
|
|
label={t("home.stats.apps")}
|
|
|
|
|
value={formatNumber(stats.totalApps, lang)}
|
|
|
|
|
Icon={Grid2X2}
|
|
|
|
|
iconClass="bg-primary/15 text-primary"
|
|
|
|
|
/>
|
|
|
|
|
<StatCard
|
|
|
|
|
label={t("home.stats.services")}
|
|
|
|
|
value={formatNumber(stats.totalServices, lang)}
|
|
|
|
|
Icon={Coffee}
|
|
|
|
|
iconClass="bg-amber-100 text-amber-600"
|
|
|
|
|
/>
|
|
|
|
|
<StatCard
|
|
|
|
|
label={t("home.stats.messages")}
|
|
|
|
|
value={formatNumber(stats.unreadMessages, lang)}
|
|
|
|
|
Icon={MessageSquare}
|
|
|
|
|
iconClass="bg-emerald-100 text-emerald-600"
|
|
|
|
|
/>
|
|
|
|
|
<StatCard
|
|
|
|
|
label={t("home.stats.alerts")}
|
|
|
|
|
value={formatNumber(stats.unreadNotifications, lang)}
|
|
|
|
|
Icon={BellRing}
|
|
|
|
|
iconClass="bg-rose-100 text-rose-600"
|
|
|
|
|
/>
|
2026-04-20 09:20:50 +00:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Apps Grid */}
|
|
|
|
|
<div className="mb-4">
|
2026-04-20 15:05:11 +00:00
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
|
|
|
{t("home.myApps")}
|
|
|
|
|
</h3>
|
|
|
|
|
{orderedApps && orderedApps.length > 0 && (
|
|
|
|
|
<span className="text-[11px] text-muted-foreground tabular-nums">
|
|
|
|
|
{formatNumber(orderedApps.length, lang)}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{orderedApps && orderedApps.length === 0 ? (
|
|
|
|
|
<div className="glass-panel rounded-2xl p-8 text-center max-w-md mx-auto">
|
|
|
|
|
<div className="mx-auto w-12 h-12 rounded-2xl bg-primary/10 text-primary flex items-center justify-center mb-3">
|
|
|
|
|
<Sparkles size={22} />
|
2026-04-20 12:09:14 +00:00
|
|
|
</div>
|
2026-04-20 15:05:11 +00:00
|
|
|
<div className="font-semibold text-foreground">{t("home.noApps")}</div>
|
|
|
|
|
<div className="text-sm text-muted-foreground mt-1">{t("home.noAppsHint")}</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
|
|
|
<SortableContext items={sortableIds} strategy={rectSortingStrategy}>
|
|
|
|
|
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-7 lg:grid-cols-8 gap-x-3 gap-y-5">
|
|
|
|
|
{orderedApps?.map((app) => (
|
|
|
|
|
<SortableAppIcon
|
|
|
|
|
key={app.id}
|
|
|
|
|
app={app}
|
2026-04-20 15:16:19 +00:00
|
|
|
onClick={() => openApp(app)}
|
2026-04-20 15:05:11 +00:00
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</SortableContext>
|
|
|
|
|
</DndContext>
|
|
|
|
|
)}
|
2026-04-20 09:20:50 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Bottom Dock */}
|
2026-04-20 15:05:11 +00:00
|
|
|
<div className="fixed bottom-0 left-0 right-0 p-4 z-10 pointer-events-none">
|
|
|
|
|
<div className="dock-glass rounded-3xl px-6 py-3 flex items-center justify-around max-w-sm mx-auto pointer-events-auto">
|
2026-04-20 09:20:50 +00:00
|
|
|
{dockApps.slice(0, 4).map((app) => {
|
2026-04-20 15:05:11 +00:00
|
|
|
const Icon = resolveIcon(app.iconName);
|
|
|
|
|
const showBadge = app.slug === "notifications" && unreadCount > 0;
|
2026-04-20 09:20:50 +00:00
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={app.id}
|
2026-04-20 15:16:19 +00:00
|
|
|
onClick={() => openApp(app)}
|
2026-04-20 15:05:11 +00:00
|
|
|
className="relative flex flex-col items-center gap-1 group"
|
2026-04-20 09:20:50 +00:00
|
|
|
>
|
|
|
|
|
<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>
|
2026-04-20 15:05:11 +00:00
|
|
|
{showBadge && (
|
|
|
|
|
<span className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground text-[10px] rounded-full min-w-[16px] h-4 px-1 flex items-center justify-center font-bold tabular-nums">
|
|
|
|
|
{unreadCount > 9 ? "9+" : unreadCount}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-04-20 09:20:50 +00:00
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|