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

634 lines
23 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import {
useListApps,
getListAppsQueryKey,
useGetHomeStats,
getGetHomeStatsQueryKey,
useListNotifications,
getListNotificationsQueryKey,
useLogout,
useUpdateLanguage,
useUpdateMyAppOrder,
logAppOpen,
getGetMeQueryKey,
type App,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import {
Bell,
Inbox,
LogOut,
Globe,
Grid2X2,
Coffee,
MessageSquare,
BellRing,
Sparkles,
} from "lucide-react";
import * as LucideIcons from "lucide-react";
import type { LucideIcon } 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";
import {
formatDate,
formatTime,
formatNumber,
} from "@/lib/i18n-format";
import {
Clock,
useNow,
AnalogClockWidget,
useHomeClockVisibility,
useTopbarClockVisibility,
useHomeClockSize,
useHomeClockPosition,
type HomeClockSize,
} from "@/components/clock";
import { ClockStylePicker } from "@/components/clock-style-picker";
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;
}
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 AppIconContent({ app }: { app: { nameAr: string; nameEn: string; iconName: string; color: string } }) {
const { i18n: i18nInstance } = useTranslation();
const lang = i18nInstance.language;
const name = lang === "ar" ? app.nameAr : app.nameEn;
const Icon = resolveIcon(app.iconName);
return (
<>
<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" />
</div>
<span className="text-[11px] text-foreground/85 font-medium text-center leading-tight max-w-[78px] truncate">
{name}
</span>
</>
);
}
function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `app-${app.id}` });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
zIndex: isDragging ? 20 : "auto",
touchAction: "none",
};
return (
<button
type="button"
ref={setNodeRef}
style={style}
onClick={onClick}
className="flex flex-col items-center gap-2 group"
{...attributes}
{...listeners}
>
<AppIconContent app={app} />
</button>
);
}
const CLOCK_TILE_ID = "__clock";
function useGridCols(): number {
const compute = () => {
if (typeof window === "undefined") return 8;
const w = window.innerWidth;
if (w >= 1024) return 8;
if (w >= 768) return 7;
if (w >= 640) return 6;
return 4;
};
const [cols, setCols] = useState<number>(compute);
useEffect(() => {
const onResize = () => setCols(compute());
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return cols;
}
function SortableClockTile({
time,
lang,
hour12,
size,
onToggleSize,
pinColumn,
}: {
time: Date;
lang: string;
hour12: boolean | null | undefined;
size: HomeClockSize;
onToggleSize: () => void;
pinColumn?: number;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: CLOCK_TILE_ID,
});
const isLarge = size === "large";
const px = isLarge ? 168 : 78;
const effectivePinColumn =
pinColumn && isLarge ? Math.max(pinColumn - 1, 1) : pinColumn;
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
zIndex: isDragging ? 20 : "auto",
touchAction: "none",
...(effectivePinColumn && !isDragging
? { gridColumnStart: effectivePinColumn, gridRowStart: 1 }
: {}),
};
const longPressTimer = useRef<number | null>(null);
const startPos = useRef<{ x: number; y: number } | null>(null);
const clearTimer = () => {
if (longPressTimer.current !== null) {
window.clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
};
useEffect(() => {
if (isDragging) clearTimer();
}, [isDragging]);
const dndPointerDown = listeners?.onPointerDown as
| ((e: React.PointerEvent) => void)
| undefined;
const onPointerDown = (e: React.PointerEvent) => {
startPos.current = { x: e.clientX, y: e.clientY };
clearTimer();
longPressTimer.current = window.setTimeout(() => {
onToggleSize();
}, 500);
dndPointerDown?.(e);
};
const onPointerMove = (e: React.PointerEvent) => {
if (!startPos.current) return;
const dx = e.clientX - startPos.current.x;
const dy = e.clientY - startPos.current.y;
if (dx * dx + dy * dy > 64) clearTimer();
};
const onPointerUp = () => {
clearTimer();
startPos.current = null;
};
const { onPointerDown: _ignored, ...restListeners } = listeners ?? {};
return (
<div
ref={setNodeRef}
style={style}
className={`flex flex-col items-center gap-2 group select-none ${isLarge ? "col-span-2 row-span-2" : ""}`}
{...attributes}
{...restListeners}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<div
className={`relative flex items-center justify-center text-foreground group-hover:scale-105 group-active:scale-95 transition-transform ${
isLarge ? "w-[168px] h-[168px]" : "w-[78px] h-[78px]"
}`}
aria-label="Clock"
>
<AnalogClockWidget
time={time}
lang={lang}
hour12={hour12}
size={px - (isLarge ? 16 : 10)}
showLabels={false}
/>
</div>
<span className="text-[11px] text-foreground/85 font-medium text-center leading-tight max-w-[78px] truncate">
{formatTime(time, lang, {
hour: "2-digit",
minute: "2-digit",
hour12: resolveClockHour12Bool(hour12),
})}
</span>
</div>
);
}
function resolveClockHour12Bool(v: boolean | null | undefined): boolean {
return v ?? false;
}
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>
);
}
export default function HomePage() {
const { t, i18n: i18nInstance } = useTranslation();
const [, setLocation] = useLocation();
const { user } = useAuth();
const queryClient = useQueryClient();
const time = useNow();
const lang = i18nInstance.language;
const { data: apps } = useListApps({ query: { queryKey: getListAppsQueryKey() } });
const updateOrder = useUpdateMyAppOrder();
const [orderedApps, setOrderedApps] = useState<App[] | null>(null);
useEffect(() => {
if (!apps) return;
const activeApps = apps.filter((a) => a.isActive);
setOrderedApps((prev) => {
if (!prev) return activeApps;
const byId = new Map(activeApps.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 = activeApps.filter((a) => !keptIds.has(a.id));
return [...kept, ...added];
});
}, [apps]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }),
);
const gridCols = useGridCols();
2026-04-21 13:16:15 +00:00
const [homeClockVisible, setHomeClockVisible] = useHomeClockVisibility();
const [topbarClockVisible] = useTopbarClockVisibility();
const [clockSize, setClockSize] = useHomeClockSize(user?.id);
const [clockPos, setClockPos] = useHomeClockPosition(user?.id);
const toggleClockSize = () => setClockSize(clockSize === "large" ? "compact" : "large");
const appCount = (orderedApps ?? []).length;
const isClockUnset = clockPos < 0;
// Only meaningful in RTL: when the user drags the clock past the last
// app, treat it as "pin to the absolute far-left cell" so they can
// reach that cell even when there are fewer apps than columns.
// In LTR the far-right is already reachable naturally so we don't
// hijack the saved index there.
const isRtlClockAtEnd = lang === "ar" && clockPos >= appCount;
const shouldPinFarLeft = (isClockUnset || isRtlClockAtEnd) && lang === "ar" && homeClockVisible;
const combinedIds = useMemo(() => {
const appIds = (orderedApps ?? []).map((a) => `app-${a.id}`);
if (!homeClockVisible) return appIds;
if (isClockUnset) {
// Default placement: LTR -> front of array (visually leftmost);
// RTL -> appended to end and CSS-pinned to the last column of row 1.
return lang === "ar" ? [...appIds, CLOCK_TILE_ID] : [CLOCK_TILE_ID, ...appIds];
}
if (isRtlClockAtEnd) {
// RTL only: user dragged clock to / past the end -> pin far-left.
return [...appIds, CLOCK_TILE_ID];
}
const safePos = Math.min(Math.max(clockPos, 0), appIds.length);
const out: string[] = [...appIds];
out.splice(safePos, 0, CLOCK_TILE_ID);
return out;
}, [orderedApps, clockPos, homeClockVisible, lang, isClockUnset, isRtlClockAtEnd]);
const clockPinColumn = shouldPinFarLeft ? Math.max(gridCols, 1) : undefined;
const sortableIds = combinedIds;
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !orderedApps) return;
const oldIndex = combinedIds.findIndex((id) => id === active.id);
const newIndex = combinedIds.findIndex((id) => id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
const nextCombined = arrayMove(combinedIds, oldIndex, newIndex);
const newClockPos = nextCombined.findIndex((id) => id === CLOCK_TILE_ID);
if (newClockPos >= 0 && newClockPos !== clockPos) setClockPos(newClockPos);
const newAppOrder = nextCombined
.filter((id) => id !== CLOCK_TILE_ID)
.map((id) => Number(String(id).slice(4)))
.map((id) => orderedApps.find((a) => a.id === id))
.filter((a): a is App => a !== undefined);
const sameAppOrder =
newAppOrder.length === orderedApps.length &&
newAppOrder.every((a, i) => a.id === orderedApps[i].id);
if (sameAppOrder) return;
const previous = orderedApps;
setOrderedApps(newAppOrder);
updateOrder.mutate(
{ data: { order: newAppOrder.map((a) => a.id) } },
{
onSuccess: (data) => {
setOrderedApps(data);
queryClient.setQueryData(getListAppsQueryKey(), data);
},
onError: () => {
setOrderedApps(previous);
queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() });
},
},
);
};
const { data: stats } = useGetHomeStats({ query: { queryKey: getGetHomeStatsQueryKey() } });
const { data: notifications } = useListNotifications({ query: { queryKey: getListNotificationsQueryKey() } });
const logout = useLogout();
const updateLanguage = useUpdateLanguage();
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);
};
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 canReceiveOrders =
isAdmin || (user?.roles?.includes("order_receiver") ?? 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)) ?? [];
const initials = (displayName || "?").trim().slice(0, 1).toUpperCase();
return (
<div className="min-h-screen os-bg flex flex-col overflow-hidden relative">
{/*
Status Bar
--------------------------------------------------------------
Sizing scales by device class so the bar feels natural at each
size — phones stay compact (the original design), iPad gets a
noticeably taller bar with bigger tap targets so it doesn't
look lost on a larger screen, and desktop matches iPad. We
switch at the `md:` breakpoint (≥768px), which is the standard
iPad-portrait threshold; mobile (<768px) renders identically
to before this change.
*/}
<div className="topbar-glass px-4 sm:px-6 md:px-8 py-3 md:py-4 flex items-center justify-between gap-3 md:gap-5 z-10 sticky top-0">
{/* Identity */}
<div className="flex items-center gap-2.5 md:gap-3.5 min-w-0 shrink basis-0 flex-1">
<div className="w-9 h-9 md:w-12 md:h-12 rounded-full bg-primary/15 text-primary flex items-center justify-center font-bold text-sm md:text-lg shrink-0">
{initials}
</div>
<div className="min-w-0 hidden sm:block">
<div className="text-foreground font-semibold text-sm md:text-base truncate leading-tight">{displayName}</div>
{isAdmin && (
<div className="text-[10px] md:text-xs text-muted-foreground leading-tight">Admin</div>
)}
</div>
</div>
{/* Clock + dates */}
{topbarClockVisible && (
<div className="px-2 md:px-3 shrink-0">
<Clock
style={user?.clockStyle ?? null}
hour12={user?.clockHour12 ?? null}
time={time}
lang={lang}
/>
</div>
)}
{/*
Controls — buttons grow on iPad+ via padding + icon size.
Lucide icons size via `width`/`height` SVG attrs; we drop the
numeric `size` prop and use Tailwind classes so the icon
itself can scale at the `md:` breakpoint.
*/}
<div className="flex items-center gap-1 md:gap-2 shrink basis-0 flex-1 justify-end">
<ClockStylePicker
currentStyle={user?.clockStyle ?? null}
currentHour12={user?.clockHour12 ?? null}
/>
<button
onClick={toggleLanguage}
aria-label="Toggle language"
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5"
>
<Globe className="w-[18px] h-[18px] md:w-6 md:h-6" />
</button>
{canReceiveOrders && (
<button
onClick={() => setLocation("/orders/incoming")}
aria-label={t("incomingOrders.linkLabel")}
className="p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
>
<Inbox className="w-[18px] h-[18px] md:w-6 md:h-6" />
</button>
)}
<button
onClick={() => setLocation("/notifications")}
className="relative p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
>
<Bell className="w-[18px] h-[18px] md:w-6 md:h-6" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 bg-destructive text-destructive-foreground text-[10px] md:text-xs rounded-full min-w-[16px] md:min-w-[20px] h-4 md:h-5 px-1 flex items-center justify-center font-bold tabular-nums">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
<button
onClick={handleLogout}
className="p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
>
<LogOut className="w-[18px] h-[18px] md:w-6 md:h-6" />
</button>
</div>
</div>
{/* Main Content */}
<div className="flex-1 px-5 sm:px-8 pt-6 pb-32 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 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"
/>
</div>
)}
{/* Apps Grid */}
<div className="mb-4">
{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} />
</div>
<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 grid-flow-row-dense">
{combinedIds.map((id) => {
if (id === CLOCK_TILE_ID) {
if (!homeClockVisible) return null;
return (
<SortableClockTile
key={CLOCK_TILE_ID}
time={time}
lang={lang}
hour12={user?.clockHour12 ?? null}
size={clockSize}
onToggleSize={toggleClockSize}
pinColumn={clockPinColumn}
/>
);
}
const appId = Number(String(id).slice(4));
const app = orderedApps?.find((a) => a.id === appId);
if (!app) return null;
return (
<SortableAppIcon
key={app.id}
app={app}
onClick={() => openApp(app)}
/>
);
})}
</div>
</SortableContext>
</DndContext>
)}
</div>
</div>
{/* Bottom Dock */}
<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">
{dockApps.slice(0, 4).map((app) => {
const Icon = resolveIcon(app.iconName);
const showBadge = app.slug === "notifications" && unreadCount > 0;
return (
<button
key={app.id}
onClick={() => openApp(app)}
className="relative 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>
{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>
)}
</button>
);
})}
</div>
</div>
</div>
);
}