Files
TX/artifacts/teaboy-os/src/pages/home.tsx
T

299 lines
12 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import {
useListApps,
getListAppsQueryKey,
useGetHomeStats,
getGetHomeStatsQueryKey,
useListNotifications,
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();
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";
}
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"
>
<div className={`icon-tile w-16 h-16 ${gradientForColor(app.color)} group-hover:scale-105`}>
<Icon size={28} color="#FFFFFF" />
</div>
<span className="text-xs text-foreground/80 font-medium text-center leading-tight max-w-16 truncate">
{name}
</span>
</button>
);
}
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();
const { user } = useAuth();
const queryClient = useQueryClient();
const [time, setTime] = useState(new Date());
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() } });
const logout = useLogout();
const updateLanguage = useUpdateLanguage();
useEffect(() => {
const timer = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
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);
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;
const handleLogout = () => {
logout.mutate(undefined, {
onSuccess: () => {
queryClient.clear();
setLocation("/login");
},
});
};
const toggleLanguage = () => {
const newLang = lang === "ar" ? "en" : "ar";
i18n.changeLanguage(newLang);
updateLanguage.mutate(
{ data: { language: newLang } },
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }) },
);
};
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">
{/* Status Bar */}
<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>
</div>
<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>
</div>
<div className="flex items-center gap-2">
<button
onClick={toggleLanguage}
className="text-muted-foreground hover:text-foreground transition-colors text-xs font-medium px-2 py-1 rounded-lg hover:bg-slate-100"
>
{t("common.language")}
</button>
<button
onClick={() => setLocation("/notifications")}
className="relative p-1.5 rounded-lg hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
>
<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}
className="p-1.5 rounded-lg hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
>
<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 && (
<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>
<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>
{/* Bottom Dock */}
<div className="fixed bottom-0 left-0 right-0 p-4 z-10">
<div className="dock-glass rounded-3xl px-6 py-3 flex items-center justify-around max-w-sm mx-auto">
{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>
);
}