Files
TX/artifacts/teaboy-os/src/pages/home.tsx
T
Riyadh ccfe0ea74a Add per-user clock style preference for the home status bar.
Original task #20: Let each user pick their own home-screen clock
style (analog/digital/minimal/etc.), persisted on the user record
and restored on login / other devices. Default = "full".

Changes:
- DB: added `clock_style varchar(30)` (nullable) to `users`
  (lib/db/src/schema/users.ts) and applied via direct ALTER TABLE
  (drizzle-kit push had unrelated interactive prompts about
  app_opens / user_sessions which were not safe to answer).
- OpenAPI: added `ClockStyle` enum (full/digital/digital-no-seconds
  /analog/minimal), `UpdateClockStyleBody`, exposed `clockStyle`
  on AuthUser and UserProfile, and added PATCH /auth/me/clock-style.
  Regenerated typed client and zod schemas.
- API: `buildAuthUser` and `buildUserProfile` include `clockStyle`;
  new authenticated endpoint updates the current user's style and
  returns the refreshed AuthUser.
- Frontend: new `Clock` component (artifacts/teaboy-os/src/
  components/clock.tsx) with five variants sharing a single `useNow`
  tick hook, plus an SVG analog clock; honors existing
  Latin-digit/locale formatting helpers. New `ClockStylePicker`
  (popover) shown in the status bar with a live preview for each
  option and optimistic update through the AuthUser query cache.
- home.tsx replaces the hard-coded clock block with `<Clock>` driven
  by `user.clockStyle`; trigger button placed next to the language
  toggle.
- i18n: added `home.clockStyle.label` and per-style option labels
  to en.json and ar.json.

Verified via e2e: register → default "full" rendered → switch to
analog → reload → analog persists → switch to minimal → time-only
renders. RTL layout + Latin digits both correct after language
toggle.
2026-04-20 16:40:49 +00:00

416 lines
15 KiB
TypeScript

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,
logAppOpen,
getGetMeQueryKey,
type App,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import {
Bell,
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,
formatWeekday,
formatNumber,
greetingKey,
} from "@/lib/i18n-format";
import { Clock, useNow } 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.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>
);
}
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;
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];
});
}, [apps]);
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 previous = orderedApps;
const next = arrayMove(orderedApps, oldIndex, newIndex);
setOrderedApps(next);
updateOrder.mutate(
{ data: { order: next.map((a) => a.id) } },
{
onSuccess: (data) => {
setOrderedApps(data);
queryClient.setQueryData(getListAppsQueryKey(), data);
},
onError: () => {
setOrderedApps(previous);
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();
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 dayName = formatWeekday(time, lang);
const gregorianDate = formatDate(time, lang);
const unreadCount = notifications?.filter((n) => !n.isRead).length ?? stats?.unreadNotifications ?? 0;
const displayName = lang === "ar"
? (user?.displayNameAr ?? user?.username ?? "")
: (user?.displayNameEn ?? user?.username ?? "");
const isAdmin = user?.roles?.includes("admin") ?? false;
const greeting = t(`home.greeting.${greetingKey(time)}`, { name: displayName });
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 */}
<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>
</div>
{/* Clock + dates */}
<div className="px-2 shrink-0">
<Clock style={user?.clockStyle ?? null} time={time} lang={lang} />
</div>
{/* Controls */}
<div className="flex items-center gap-1 shrink basis-0 flex-1 justify-end">
<ClockStylePicker currentStyle={user?.clockStyle ?? null} />
<button
onClick={toggleLanguage}
aria-label="Toggle language"
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 rounded-lg hover:bg-foreground/5"
>
<Globe size={18} />
</button>
<button
onClick={() => setLocation("/notifications")}
className="relative p-1.5 rounded-lg hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
>
<Bell size={18} />
{unreadCount > 0 && (
<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">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
<button
onClick={handleLogout}
className="p-1.5 rounded-lg hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
>
<LogOut size={18} />
</button>
</div>
</div>
{/* Main Content */}
<div className="flex-1 px-5 sm:px-8 pt-6 pb-32 overflow-y-auto">
{/* Greeting */}
<div className="mb-6 max-w-3xl">
<h1 className="text-2xl sm:text-3xl font-bold text-foreground tracking-tight">
{greeting}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{dayName} · <span className="tabular-nums">{gregorianDate}</span>
</p>
</div>
{/* 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">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t("home.myApps")}
</h3>
{orderedApps && orderedApps.length > 0 && (
<span className="text-[11px] text-muted-foreground tabular-nums">
{formatNumber(orderedApps.length, lang)}
</span>
)}
</div>
{orderedApps && orderedApps.length === 0 ? (
<div className="glass-panel rounded-2xl p-8 text-center max-w-md mx-auto">
<div className="mx-auto w-12 h-12 rounded-2xl bg-primary/10 text-primary flex items-center justify-center mb-3">
<Sparkles size={22} />
</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">
{orderedApps?.map((app) => (
<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>
);
}