2026-04-21 12:56:33 +00:00
|
|
|
import { useState, useEffect, useMemo, useRef } 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,
|
2026-04-21 12:56:33 +00:00
|
|
|
formatTime,
|
2026-04-20 15:05:11 +00:00
|
|
|
formatNumber,
|
|
|
|
|
} from "@/lib/i18n-format";
|
2026-04-21 12:56:33 +00:00
|
|
|
import {
|
|
|
|
|
Clock,
|
|
|
|
|
useNow,
|
|
|
|
|
AnalogClockWidget,
|
|
|
|
|
useHomeClockVisibility,
|
|
|
|
|
useHomeClockSize,
|
|
|
|
|
useHomeClockPosition,
|
|
|
|
|
type HomeClockSize,
|
|
|
|
|
} from "@/components/clock";
|
2026-04-20 16:40:49 +00:00
|
|
|
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 }) {
|
2026-04-21 12:56:33 +00:00
|
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `app-${app.id}` });
|
2026-04-20 12:09:14 +00:00
|
|
|
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-21 12:56:33 +00:00
|
|
|
const CLOCK_TILE_ID = "__clock";
|
|
|
|
|
|
2026-04-21 13:40:51 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 12:56:33 +00:00
|
|
|
function SortableClockTile({
|
|
|
|
|
time,
|
|
|
|
|
lang,
|
|
|
|
|
hour12,
|
|
|
|
|
size,
|
|
|
|
|
onToggleSize,
|
|
|
|
|
}: {
|
|
|
|
|
time: Date;
|
|
|
|
|
lang: string;
|
|
|
|
|
hour12: boolean | null | undefined;
|
|
|
|
|
size: HomeClockSize;
|
|
|
|
|
onToggleSize: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
|
|
|
id: CLOCK_TILE_ID,
|
|
|
|
|
});
|
|
|
|
|
const style: React.CSSProperties = {
|
|
|
|
|
transform: CSS.Transform.toString(transform),
|
|
|
|
|
transition,
|
|
|
|
|
opacity: isDragging ? 0.6 : 1,
|
|
|
|
|
zIndex: isDragging ? 20 : "auto",
|
|
|
|
|
touchAction: "none",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-21 13:04:45 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (isDragging) clearTimer();
|
|
|
|
|
}, [isDragging]);
|
|
|
|
|
|
2026-04-21 12:56:33 +00:00
|
|
|
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 isLarge = size === "large";
|
|
|
|
|
const px = isLarge ? 168 : 78;
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-21 13:39:22 +00:00
|
|
|
className={`relative flex items-center justify-center text-foreground group-hover:scale-105 group-active:scale-95 transition-transform ${
|
2026-04-21 12:56:33 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
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 } }),
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-21 13:40:51 +00:00
|
|
|
const gridCols = useGridCols();
|
2026-04-21 13:16:15 +00:00
|
|
|
const [homeClockVisible, setHomeClockVisible] = useHomeClockVisibility();
|
2026-04-21 13:03:05 +00:00
|
|
|
const [clockSize, setClockSize] = useHomeClockSize(user?.id);
|
|
|
|
|
const [clockPos, setClockPos] = useHomeClockPosition(user?.id);
|
2026-04-21 12:56:33 +00:00
|
|
|
const toggleClockSize = () => setClockSize(clockSize === "large" ? "compact" : "large");
|
|
|
|
|
|
|
|
|
|
const combinedIds = useMemo(() => {
|
|
|
|
|
const appIds = (orderedApps ?? []).map((a) => `app-${a.id}`);
|
|
|
|
|
if (!homeClockVisible) return appIds;
|
2026-04-21 13:40:51 +00:00
|
|
|
const cols = gridCols;
|
|
|
|
|
const lastOfFirstRow = Math.min(appIds.length, Math.max(cols - 1, 0));
|
|
|
|
|
const defaultPos = lang === "ar" ? lastOfFirstRow : 0;
|
2026-04-21 13:39:22 +00:00
|
|
|
const resolvedPos = clockPos < 0 ? defaultPos : clockPos;
|
|
|
|
|
const safePos = Math.min(Math.max(resolvedPos, 0), appIds.length);
|
2026-04-21 12:56:33 +00:00
|
|
|
const out: string[] = [...appIds];
|
|
|
|
|
out.splice(safePos, 0, CLOCK_TILE_ID);
|
|
|
|
|
return out;
|
2026-04-21 13:40:51 +00:00
|
|
|
}, [orderedApps, clockPos, homeClockVisible, lang, gridCols]);
|
2026-04-21 12:56:33 +00:00
|
|
|
const sortableIds = combinedIds;
|
|
|
|
|
|
2026-04-20 12:09:14 +00:00
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
|
|
|
const { active, over } = event;
|
|
|
|
|
if (!over || active.id === over.id || !orderedApps) return;
|
2026-04-21 12:56:33 +00:00
|
|
|
const oldIndex = combinedIds.findIndex((id) => id === active.id);
|
|
|
|
|
const newIndex = combinedIds.findIndex((id) => id === over.id);
|
2026-04-20 12:09:14 +00:00
|
|
|
if (oldIndex < 0 || newIndex < 0) return;
|
2026-04-21 12:56:33 +00:00
|
|
|
const nextCombined = arrayMove(combinedIds, oldIndex, newIndex);
|
|
|
|
|
const newClockPos = nextCombined.findIndex((id) => id === CLOCK_TILE_ID);
|
2026-04-21 13:04:45 +00:00
|
|
|
if (newClockPos >= 0 && newClockPos !== clockPos) setClockPos(newClockPos);
|
2026-04-21 12:56:33 +00:00
|
|
|
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;
|
2026-04-20 12:13:22 +00:00
|
|
|
const previous = orderedApps;
|
2026-04-21 12:56:33 +00:00
|
|
|
setOrderedApps(newAppOrder);
|
2026-04-20 12:09:14 +00:00
|
|
|
updateOrder.mutate(
|
2026-04-21 12:56:33 +00:00
|
|
|
{ data: { order: newAppOrder.map((a) => a.id) } },
|
2026-04-20 12:09:14 +00:00
|
|
|
{
|
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() });
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
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 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
|
|
|
|
|
|
|
|
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">
|
2026-04-21 07:16:50 +00:00
|
|
|
<Clock
|
|
|
|
|
style={user?.clockStyle ?? null}
|
|
|
|
|
hour12={user?.clockHour12 ?? 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-21 07:16:50 +00:00
|
|
|
<ClockStylePicker
|
|
|
|
|
currentStyle={user?.clockStyle ?? null}
|
|
|
|
|
currentHour12={user?.clockHour12 ?? 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">
|
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
|
|
|
{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}>
|
2026-04-21 12:56:33 +00:00
|
|
|
<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}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
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)}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|