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,
|
2026-05-05 07:51:36 +00:00
|
|
|
useListIncomingServiceOrders,
|
|
|
|
|
getListIncomingServiceOrdersQueryKey,
|
2026-04-20 09:20:50 +00:00
|
|
|
useLogout,
|
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-05-23 08:25:04 +00:00
|
|
|
import { usePushSubscription } from "@/hooks/use-push-subscription";
|
2026-05-05 15:01:18 +00:00
|
|
|
import { useReceivedNotes } from "@/lib/notes-api";
|
2026-05-05 16:26:13 +00:00
|
|
|
import { useIncomingNotePopup } from "@/contexts/IncomingNotePopupContext";
|
2026-04-20 15:05:11 +00:00
|
|
|
import {
|
|
|
|
|
Bell,
|
2026-04-21 18:58:19 +00:00
|
|
|
Inbox,
|
2026-05-13 15:15:31 +00:00
|
|
|
LogOut,
|
2026-04-20 15:05:11 +00:00
|
|
|
Grid2X2,
|
|
|
|
|
Coffee,
|
|
|
|
|
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 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-05-12 12:23:38 +00:00
|
|
|
import { resolveServiceImageUrl } from "@/lib/image-url";
|
2026-04-21 12:56:33 +00:00
|
|
|
import {
|
|
|
|
|
Clock,
|
|
|
|
|
useNow,
|
|
|
|
|
AnalogClockWidget,
|
|
|
|
|
useHomeClockVisibility,
|
2026-04-21 18:02:45 +00:00
|
|
|
useTopbarClockVisibility,
|
2026-04-21 12:56:33 +00:00
|
|
|
useHomeClockSize,
|
|
|
|
|
useHomeClockPosition,
|
|
|
|
|
type HomeClockSize,
|
|
|
|
|
} from "@/components/clock";
|
2026-05-13 14:46:50 +00:00
|
|
|
import { SettingsPanel } from "@/components/settings-panel";
|
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();
|
2026-05-18 13:52:42 +00:00
|
|
|
// #603: each built-in app now ships with a distinct hue so the four
|
|
|
|
|
// default tiles (Notes/Services/Meetings/Admin) read as four different
|
|
|
|
|
// colours instead of three blues + one orange. New patterns added for
|
|
|
|
|
// yellow (#eab308 / facc15) and red (#ef4444 / dc2626), and the green
|
|
|
|
|
// branch already accepts the new #22c55e Meetings colour. The purple
|
|
|
|
|
// branch stays for any admin who manually picks a purple via the
|
|
|
|
|
// Apps editor, but no seeded row uses it any more.
|
|
|
|
|
if (c.includes("ef4444") || c.includes("dc2626") || c.includes("red")) return "icon-tile-red";
|
|
|
|
|
if (c.includes("eab308") || c.includes("facc15") || c.includes("yellow")) return "icon-tile-yellow";
|
2026-04-20 10:25:54 +00:00
|
|
|
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-05-05 15:01:18 +00:00
|
|
|
function AppIconContent({
|
|
|
|
|
app,
|
|
|
|
|
badgeCount = 0,
|
2026-05-05 16:26:13 +00:00
|
|
|
pulse = false,
|
2026-05-05 15:01:18 +00:00
|
|
|
}: {
|
2026-05-12 12:23:38 +00:00
|
|
|
app: { nameAr: string; nameEn: string; iconName: string; color: string; imageUrl?: string | null };
|
2026-05-05 15:01:18 +00:00
|
|
|
badgeCount?: number;
|
2026-05-05 16:26:13 +00:00
|
|
|
pulse?: boolean;
|
2026-05-05 15:01:18 +00:00
|
|
|
}) {
|
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-05-14 06:23:49 +00:00
|
|
|
// when an admin uploads a custom image, render it in place
|
2026-05-12 12:23:38 +00:00
|
|
|
// of the Lucide icon. The image fills the tile (object-cover) so the
|
|
|
|
|
// gradient background still shows through transparent PNGs.
|
|
|
|
|
const resolvedImage = resolveServiceImageUrl(app.imageUrl ?? null);
|
2026-04-20 09:20:50 +00:00
|
|
|
|
|
|
|
|
return (
|
2026-04-20 15:16:19 +00:00
|
|
|
<>
|
2026-05-05 15:01:18 +00:00
|
|
|
<div className={`relative icon-tile w-[68px] h-[68px] sm:w-[72px] sm:h-[72px] ${gradientForColor(app.color)} group-hover:scale-105 group-active:scale-95`}>
|
2026-05-05 16:26:13 +00:00
|
|
|
{pulse && (
|
|
|
|
|
<span
|
|
|
|
|
data-testid={`app-icon-pulse-${app.iconName}`}
|
|
|
|
|
className="absolute inset-0 rounded-[inherit] ring-4 ring-amber-400/70 animate-ping pointer-events-none"
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-05-12 12:23:38 +00:00
|
|
|
{resolvedImage ? (
|
|
|
|
|
<img
|
|
|
|
|
src={resolvedImage}
|
|
|
|
|
alt=""
|
|
|
|
|
className="absolute inset-0 w-full h-full object-cover rounded-[inherit]"
|
|
|
|
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<Icon size={30} color="#FFFFFF" />
|
|
|
|
|
)}
|
2026-05-05 15:01:18 +00:00
|
|
|
{badgeCount > 0 && (
|
|
|
|
|
<span className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground text-[10px] rounded-full min-w-[18px] h-[18px] px-1 flex items-center justify-center font-bold tabular-nums shadow">
|
|
|
|
|
{badgeCount > 9 ? "9+" : badgeCount}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-04-20 09:20:50 +00:00
|
|
|
</div>
|
2026-05-12 08:47:16 +00:00
|
|
|
<span
|
2026-05-16 10:18:07 +00:00
|
|
|
lang={lang === "ar" ? "ar" : "en"}
|
|
|
|
|
dir={lang === "ar" ? "rtl" : "ltr"}
|
2026-05-12 08:47:16 +00:00
|
|
|
className="text-[11px] text-foreground/85 font-medium text-center max-w-[78px] block"
|
|
|
|
|
style={{
|
2026-05-16 10:18:07 +00:00
|
|
|
// Pin an Arabic-capable face so iOS Safari doesn't fall back
|
|
|
|
|
// to a Latin font (which drops the dots under final ي in
|
|
|
|
|
// labels like "خدماتي"). Falls back to the Latin face for
|
|
|
|
|
// English labels via the second family in the stack.
|
|
|
|
|
fontFamily:
|
|
|
|
|
lang === "ar"
|
|
|
|
|
? '"DIN Next LT Arabic", "Helvetica Neue LT Arabic", "Tajawal", sans-serif'
|
|
|
|
|
: '"Helvetica Neue", system-ui, sans-serif',
|
2026-05-12 08:47:16 +00:00
|
|
|
lineHeight: 1.6,
|
|
|
|
|
paddingTop: 2,
|
|
|
|
|
overflow: "hidden",
|
|
|
|
|
textOverflow: "ellipsis",
|
|
|
|
|
whiteSpace: "nowrap",
|
|
|
|
|
}}
|
|
|
|
|
>
|
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-05-05 15:01:18 +00:00
|
|
|
function SortableAppIcon({
|
|
|
|
|
app,
|
|
|
|
|
onClick,
|
|
|
|
|
badgeCount = 0,
|
2026-05-05 16:26:13 +00:00
|
|
|
pulse = false,
|
2026-05-05 15:01:18 +00:00
|
|
|
}: {
|
|
|
|
|
app: App;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
badgeCount?: number;
|
2026-05-05 16:26:13 +00:00
|
|
|
pulse?: boolean;
|
2026-05-05 15:01:18 +00:00
|
|
|
}) {
|
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}
|
|
|
|
|
>
|
2026-05-05 16:26:13 +00:00
|
|
|
<AppIconContent app={app} badgeCount={badgeCount} pulse={pulse} />
|
2026-04-20 15:16:19 +00:00
|
|
|
</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,
|
2026-04-21 17:45:06 +00:00
|
|
|
pinColumn,
|
2026-04-21 12:56:33 +00:00
|
|
|
}: {
|
|
|
|
|
time: Date;
|
|
|
|
|
lang: string;
|
|
|
|
|
hour12: boolean | null | undefined;
|
|
|
|
|
size: HomeClockSize;
|
|
|
|
|
onToggleSize: () => void;
|
2026-04-21 17:45:06 +00:00
|
|
|
pinColumn?: number;
|
2026-04-21 12:56:33 +00:00
|
|
|
}) {
|
|
|
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
|
|
|
id: CLOCK_TILE_ID,
|
|
|
|
|
});
|
2026-04-21 17:51:35 +00:00
|
|
|
const isLarge = size === "large";
|
|
|
|
|
const px = isLarge ? 168 : 78;
|
|
|
|
|
const effectivePinColumn =
|
|
|
|
|
pinColumn && isLarge ? Math.max(pinColumn - 1, 1) : pinColumn;
|
2026-04-21 12:56:33 +00:00
|
|
|
const style: React.CSSProperties = {
|
|
|
|
|
transform: CSS.Transform.toString(transform),
|
|
|
|
|
transition,
|
|
|
|
|
opacity: isDragging ? 0.6 : 1,
|
|
|
|
|
zIndex: isDragging ? 20 : "auto",
|
|
|
|
|
touchAction: "none",
|
2026-04-21 17:50:23 +00:00
|
|
|
...(effectivePinColumn && !isDragging
|
|
|
|
|
? { gridColumnStart: effectivePinColumn, gridRowStart: 1 }
|
2026-04-21 17:45:06 +00:00
|
|
|
: {}),
|
2026-04-21 12:56:33 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 { 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;
|
2026-05-17 15:42:29 +00:00
|
|
|
const activeApps = apps.filter((a) => a.isActive);
|
2026-04-20 12:15:01 +00:00
|
|
|
setOrderedApps((prev) => {
|
2026-04-27 09:06:23 +00:00
|
|
|
if (!prev) return activeApps;
|
|
|
|
|
const byId = new Map(activeApps.map((a) => [a.id, a]));
|
2026-04-20 12:15:01 +00:00
|
|
|
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));
|
2026-04-27 09:06:23 +00:00
|
|
|
const added = activeApps.filter((a) => !keptIds.has(a.id));
|
2026-04-20 12:15:01 +00:00
|
|
|
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 18:04:17 +00:00
|
|
|
const [topbarClockVisible] = useTopbarClockVisibility();
|
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");
|
|
|
|
|
|
2026-04-21 17:53:16 +00:00
|
|
|
const appCount = (orderedApps ?? []).length;
|
2026-04-21 17:45:06 +00:00
|
|
|
const isClockUnset = clockPos < 0;
|
2026-04-21 17:58:59 +00:00
|
|
|
// 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;
|
2026-04-21 12:56:33 +00:00
|
|
|
const combinedIds = useMemo(() => {
|
|
|
|
|
const appIds = (orderedApps ?? []).map((a) => `app-${a.id}`);
|
|
|
|
|
if (!homeClockVisible) return appIds;
|
2026-04-21 17:58:59 +00:00
|
|
|
if (isClockUnset) {
|
|
|
|
|
// Default placement: LTR -> front of array (visually leftmost);
|
2026-04-21 17:53:16 +00:00
|
|
|
// RTL -> appended to end and CSS-pinned to the last column of row 1.
|
2026-04-21 17:45:06 +00:00
|
|
|
return lang === "ar" ? [...appIds, CLOCK_TILE_ID] : [CLOCK_TILE_ID, ...appIds];
|
|
|
|
|
}
|
2026-04-21 17:58:59 +00:00
|
|
|
if (isRtlClockAtEnd) {
|
|
|
|
|
// RTL only: user dragged clock to / past the end -> pin far-left.
|
|
|
|
|
return [...appIds, CLOCK_TILE_ID];
|
|
|
|
|
}
|
2026-04-21 17:45:06 +00:00
|
|
|
const safePos = Math.min(Math.max(clockPos, 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 17:58:59 +00:00
|
|
|
}, [orderedApps, clockPos, homeClockVisible, lang, isClockUnset, isRtlClockAtEnd]);
|
2026-04-21 17:53:16 +00:00
|
|
|
const clockPinColumn = shouldPinFarLeft ? Math.max(gridCols, 1) : undefined;
|
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() } });
|
|
|
|
|
|
2026-05-05 07:51:36 +00:00
|
|
|
const isAdmin = user?.roles?.includes("admin") ?? false;
|
|
|
|
|
const canReceiveOrders =
|
|
|
|
|
isAdmin || (user?.roles?.includes("order_receiver") ?? false);
|
|
|
|
|
const { data: incomingOrders } = useListIncomingServiceOrders({
|
|
|
|
|
query: {
|
|
|
|
|
queryKey: getListIncomingServiceOrdersQueryKey(),
|
|
|
|
|
enabled: canReceiveOrders,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const pendingOrdersCount =
|
|
|
|
|
incomingOrders?.filter((o) => o.status === "pending" && !o.assignedTo).length ?? 0;
|
|
|
|
|
|
2026-05-05 15:01:18 +00:00
|
|
|
// Unread Notes badge — drives the count shown on the Notes app tile (and
|
|
|
|
|
// dock entry, if present). We reuse the existing /notes/received hook so
|
|
|
|
|
// socket invalidations on note_received automatically refresh this.
|
|
|
|
|
const { data: receivedNotes } = useReceivedNotes(false);
|
|
|
|
|
const unreadNotesCount =
|
|
|
|
|
receivedNotes?.filter((n) => n.status === "unread").length ?? 0;
|
|
|
|
|
|
2026-05-05 16:26:13 +00:00
|
|
|
// Persistent visual cue while an incoming-note popup is queued: the Notes
|
|
|
|
|
// tile pulses + shows the queue size as its badge so the user knows there's
|
|
|
|
|
// an attention-grabbing note waiting even after they dismiss the modal or
|
|
|
|
|
// navigate elsewhere on the home screen.
|
|
|
|
|
const { queueLength: notePopupQueueLength } = useIncomingNotePopup();
|
|
|
|
|
const notesBadgeCount = Math.max(unreadNotesCount, notePopupQueueLength);
|
|
|
|
|
const notesPulse = notePopupQueueLength > 0;
|
|
|
|
|
|
2026-04-20 09:20:50 +00:00
|
|
|
const logout = useLogout();
|
2026-05-23 08:25:04 +00:00
|
|
|
const pushSub = usePushSubscription();
|
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.
|
|
|
|
|
});
|
2026-05-14 06:23:49 +00:00
|
|
|
// branch on the admin-configured open mode.
|
2026-05-12 12:23:38 +00:00
|
|
|
// external_tab — open the externalUrl in a new tab
|
|
|
|
|
// external_iframe — navigate to /embedded/:id which renders externalUrl
|
|
|
|
|
// inside an iframe shell with a back button
|
|
|
|
|
// internal (default) — navigate to the SPA route
|
|
|
|
|
const mode = app.openMode ?? "internal";
|
|
|
|
|
if (mode === "external_tab" && app.externalUrl) {
|
|
|
|
|
window.open(app.externalUrl, "_blank", "noopener,noreferrer");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (mode === "external_iframe" && app.externalUrl) {
|
|
|
|
|
setLocation(`/embedded/${app.id}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-20 15:16:19 +00:00
|
|
|
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-05-23 08:25:04 +00:00
|
|
|
const handleLogout = async () => {
|
|
|
|
|
// #626: tear down this browser's Web Push subscription BEFORE
|
|
|
|
|
// hitting /auth/logout, so the local pushManager endpoint and the
|
|
|
|
|
// server row both go away. The server-side logout also deletes
|
|
|
|
|
// every push_subscriptions row for this user as a safety net, but
|
|
|
|
|
// doing the browser unsubscribe first means the OS-level
|
|
|
|
|
// subscription is gone too — otherwise the SW would keep an
|
|
|
|
|
// orphan endpoint that the next signed-in user could inherit on
|
|
|
|
|
// resubscribe.
|
|
|
|
|
// Race the browser unsubscribe against a 1.5s timeout. `disable()`
|
|
|
|
|
// awaits `navigator.serviceWorker.ready`, which can hang forever
|
|
|
|
|
// if the SW never activates (rare but observed on first iOS PWA
|
|
|
|
|
// launch). A stalled unsubscribe must not block the user from
|
|
|
|
|
// signing out — the server-side delete in /auth/logout is the
|
|
|
|
|
// authoritative cleanup, so a missed browser unsubscribe just
|
|
|
|
|
// leaves an orphan pushManager endpoint that the OS will GC on
|
|
|
|
|
// its own schedule.
|
|
|
|
|
try {
|
|
|
|
|
await Promise.race([
|
|
|
|
|
pushSub.disable(),
|
|
|
|
|
new Promise<void>((resolve) => setTimeout(resolve, 1500)),
|
|
|
|
|
]);
|
|
|
|
|
} catch {
|
|
|
|
|
// Best-effort: a failed unsubscribe must not block sign-out.
|
|
|
|
|
}
|
2026-04-20 09:20:50 +00:00
|
|
|
logout.mutate(undefined, {
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.clear();
|
|
|
|
|
setLocation("/login");
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-17 14:42:48 +00:00
|
|
|
// #571: drop the Notifications tile from the bottom dock — the bell
|
|
|
|
|
// icon in the top bar (with the same unread badge) is the canonical
|
|
|
|
|
// entry point, so a second affordance for the same destination on
|
|
|
|
|
// the home screen was redundant. The bell still routes to
|
|
|
|
|
// `/notifications` (see the top-bar button below).
|
|
|
|
|
const dockApps = (orderedApps ?? apps)?.filter((a) => ["services", "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 (
|
2026-05-14 06:23:49 +00:00
|
|
|
// removed `overflow-hidden` and switched to `min-h-[100dvh]`
|
2026-05-10 11:22:31 +00:00
|
|
|
// so iOS Safari can collapse its URL bar on scroll, matching the
|
|
|
|
|
// executive-meetings page behavior. `100dvh` (dynamic viewport
|
|
|
|
|
// height) sizes against the visible area, so the layout reflows
|
|
|
|
|
// cleanly when the URL bar shows/hides.
|
|
|
|
|
<div className="min-h-[100dvh] os-bg flex flex-col relative">
|
2026-04-29 19:13:34 +00:00
|
|
|
{/*
|
|
|
|
|
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">
|
2026-04-20 15:05:11 +00:00
|
|
|
{/* Identity */}
|
2026-04-29 19:13:34 +00:00
|
|
|
<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">
|
2026-04-20 15:05:11 +00:00
|
|
|
{initials}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="min-w-0 hidden sm:block">
|
2026-04-29 19:13:34 +00:00
|
|
|
<div className="text-foreground font-semibold text-sm md:text-base truncate leading-tight">{displayName}</div>
|
2026-04-20 15:05:11 +00:00
|
|
|
{isAdmin && (
|
2026-04-29 19:13:34 +00:00
|
|
|
<div className="text-[10px] md:text-xs text-muted-foreground leading-tight">Admin</div>
|
2026-04-20 15:05:11 +00:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-04-20 09:20:50 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-04-20 15:05:11 +00:00
|
|
|
{/* Clock + dates */}
|
2026-04-21 18:02:45 +00:00
|
|
|
{topbarClockVisible && (
|
2026-04-29 19:13:34 +00:00
|
|
|
<div className="px-2 md:px-3 shrink-0">
|
2026-04-21 18:00:59 +00:00
|
|
|
<Clock
|
|
|
|
|
style={user?.clockStyle ?? null}
|
|
|
|
|
hour12={user?.clockHour12 ?? null}
|
|
|
|
|
time={time}
|
|
|
|
|
lang={lang}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-04-20 09:20:50 +00:00
|
|
|
|
2026-04-29 19:13:34 +00:00
|
|
|
{/*
|
|
|
|
|
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.
|
|
|
|
|
*/}
|
2026-05-13 14:46:50 +00:00
|
|
|
{/*
|
2026-05-14 06:23:49 +00:00
|
|
|
: per-user UI preferences (language,
|
2026-05-13 15:15:31 +00:00
|
|
|
notification sound + vibration, master sound, clock
|
|
|
|
|
style/visibility/12-24h) live behind a single Settings
|
|
|
|
|
(gear) entry that opens the unified panel. Logout stays
|
2026-05-14 06:23:49 +00:00
|
|
|
on the topbar as a single-tap action ( reverted
|
2026-05-13 15:15:31 +00:00
|
|
|
the brief move into the panel). The cluster now shows
|
|
|
|
|
exactly Bell -> Inbox -> Settings -> Logout (Inbox is
|
|
|
|
|
conditional on the user being able to receive incoming
|
|
|
|
|
orders). The notification-center bell is the only bell on
|
|
|
|
|
the page.
|
2026-05-13 14:46:50 +00:00
|
|
|
*/}
|
2026-04-29 19:13:34 +00:00
|
|
|
<div className="flex items-center gap-1 md:gap-2 shrink basis-0 flex-1 justify-end">
|
2026-05-13 14:53:53 +00:00
|
|
|
<button
|
|
|
|
|
onClick={() => setLocation("/notifications")}
|
|
|
|
|
aria-label={t("notifications.title")}
|
|
|
|
|
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 aria-hidden="true" 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>
|
2026-04-21 18:58:19 +00:00
|
|
|
{canReceiveOrders && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setLocation("/orders/incoming")}
|
|
|
|
|
aria-label={t("incomingOrders.linkLabel")}
|
2026-05-05 07:51:36 +00:00
|
|
|
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"
|
2026-04-21 18:58:19 +00:00
|
|
|
>
|
2026-04-29 19:13:34 +00:00
|
|
|
<Inbox className="w-[18px] h-[18px] md:w-6 md:h-6" />
|
2026-05-05 07:51:36 +00:00
|
|
|
{pendingOrdersCount > 0 && (
|
|
|
|
|
<span aria-hidden="true" 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">
|
|
|
|
|
{pendingOrdersCount > 9 ? "9+" : pendingOrdersCount}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-04-21 18:58:19 +00:00
|
|
|
</button>
|
|
|
|
|
)}
|
2026-05-13 15:15:31 +00:00
|
|
|
<SettingsPanel />
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleLogout}
|
|
|
|
|
aria-label={t("nav.logout")}
|
|
|
|
|
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>
|
2026-04-20 09:20:50 +00:00
|
|
|
</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.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}
|
2026-04-21 17:45:06 +00:00
|
|
|
pinColumn={clockPinColumn}
|
2026-04-21 12:56:33 +00:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
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-05-05 16:26:13 +00:00
|
|
|
badgeCount={app.slug === "notes" ? notesBadgeCount : 0}
|
|
|
|
|
pulse={app.slug === "notes" && notesPulse}
|
2026-04-21 12:56:33 +00:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|