diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts index 122d332d..312a184e 100644 --- a/artifacts/api-server/src/routes/auth.ts +++ b/artifacts/api-server/src/routes/auth.ts @@ -15,6 +15,7 @@ import { RegisterBody, LoginBody, UpdateLanguageBody, + UpdateClockStyleBody, ForgotPasswordBody, ResetPasswordBody, VerifyResetTokenBody, @@ -36,6 +37,7 @@ function buildAuthUser(user: typeof usersTable.$inferSelect, roles: string[]) { displayNameAr: user.displayNameAr, displayNameEn: user.displayNameEn, preferredLanguage: user.preferredLanguage, + clockStyle: user.clockStyle, avatarUrl: user.avatarUrl, isActive: user.isActive, roles, @@ -324,4 +326,26 @@ router.patch("/auth/me/language", requireAuth, async (req, res): Promise = res.json(buildAuthUser(user, roles)); }); +router.patch("/auth/me/clock-style", requireAuth, async (req, res): Promise => { + const parsed = UpdateClockStyleBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const [user] = await db + .update(usersTable) + .set({ clockStyle: parsed.data.clockStyle ?? null }) + .where(eq(usersTable.id, req.session.userId!)) + .returning(); + + if (!user) { + res.status(401).json({ error: "User not found" }); + return; + } + + const roles = await getUserRoles(user.id); + res.json(buildAuthUser(user, roles)); +}); + export default router; diff --git a/artifacts/api-server/src/routes/users.ts b/artifacts/api-server/src/routes/users.ts index dacd50d0..ed42c81d 100644 --- a/artifacts/api-server/src/routes/users.ts +++ b/artifacts/api-server/src/routes/users.ts @@ -37,6 +37,7 @@ function buildUserProfile(user: typeof usersTable.$inferSelect, roles: string[]) displayNameAr: user.displayNameAr, displayNameEn: user.displayNameEn, preferredLanguage: user.preferredLanguage, + clockStyle: user.clockStyle, avatarUrl: user.avatarUrl, isActive: user.isActive, roles, diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg index 7168fb95..70c131e1 100644 Binary files a/artifacts/teaboy-os/public/opengraph.jpg and b/artifacts/teaboy-os/public/opengraph.jpg differ diff --git a/artifacts/teaboy-os/src/components/clock-style-picker.tsx b/artifacts/teaboy-os/src/components/clock-style-picker.tsx new file mode 100644 index 00000000..3af0c9b3 --- /dev/null +++ b/artifacts/teaboy-os/src/components/clock-style-picker.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { + useUpdateClockStyle, + getGetMeQueryKey, + type AuthUser, + type ClockStyle as ClockStyleType, +} from "@workspace/api-client-react"; +import { Clock as ClockIcon, Check } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Clock, CLOCK_STYLES, resolveClockStyle, useNow } from "@/components/clock"; + +type Props = { + currentStyle: ClockStyleType | null | undefined; +}; + +export function ClockStylePicker({ currentStyle }: Props) { + const { t, i18n: i18nInstance } = useTranslation(); + const lang = i18nInstance.language; + const queryClient = useQueryClient(); + const now = useNow(); + const [open, setOpen] = useState(false); + + const updateClockStyle = useUpdateClockStyle(); + const active = resolveClockStyle(currentStyle); + + const choose = (style: ClockStyleType) => { + const previous = queryClient.getQueryData(getGetMeQueryKey()); + if (previous) { + queryClient.setQueryData(getGetMeQueryKey(), { + ...previous, + clockStyle: style, + }); + } + updateClockStyle.mutate( + { data: { clockStyle: style } }, + { + onSuccess: (data) => { + queryClient.setQueryData(getGetMeQueryKey(), data); + }, + onError: () => { + if (previous) { + queryClient.setQueryData(getGetMeQueryKey(), previous); + } else { + queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); + } + }, + }, + ); + setOpen(false); + }; + + return ( + + + + + +
+ {t("home.clockStyle.label")} +
+
+ {CLOCK_STYLES.map((style) => { + const isActive = style === active; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/artifacts/teaboy-os/src/components/clock.tsx b/artifacts/teaboy-os/src/components/clock.tsx new file mode 100644 index 00000000..936a955b --- /dev/null +++ b/artifacts/teaboy-os/src/components/clock.tsx @@ -0,0 +1,197 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { ClockStyle } from "@workspace/api-client-react"; +import { + formatTime, + formatDate, + formatHijri, + formatWeekday, +} from "@/lib/i18n-format"; + +export const CLOCK_STYLES: ClockStyle[] = [ + "full", + "digital", + "digital-no-seconds", + "analog", + "minimal", +]; + +export const DEFAULT_CLOCK_STYLE: ClockStyle = "full"; + +export function resolveClockStyle(value: ClockStyle | null | undefined): ClockStyle { + if (!value) return DEFAULT_CLOCK_STYLE; + return CLOCK_STYLES.includes(value) ? value : DEFAULT_CLOCK_STYLE; +} + +export function useNow(intervalMs = 1000): Date { + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const t = setInterval(() => setNow(new Date()), intervalMs); + return () => clearInterval(t); + }, [intervalMs]); + return now; +} + +type ClockProps = { + style: ClockStyle | null | undefined; + time: Date; + lang: string; + size?: "sm" | "md"; +}; + +function AnalogClock({ time, size = "md" }: { time: Date; size?: "sm" | "md" }) { + const hours = time.getHours() % 12; + const minutes = time.getMinutes(); + const seconds = time.getSeconds(); + + const hourAngle = (hours + minutes / 60) * 30; + const minuteAngle = (minutes + seconds / 60) * 6; + const secondAngle = seconds * 6; + + const px = size === "sm" ? 38 : 52; + const center = px / 2; + + const ticks = Array.from({ length: 12 }, (_, i) => { + const angle = (i * 30 * Math.PI) / 180; + const outer = center - 2; + const inner = center - (i % 3 === 0 ? 6 : 4); + const x1 = center + Math.sin(angle) * inner; + const y1 = center - Math.cos(angle) * inner; + const x2 = center + Math.sin(angle) * outer; + const y2 = center - Math.cos(angle) * outer; + return ( + + ); + }); + + const hand = (angle: number, length: number, width: number, color: string, opacity = 1) => { + const rad = (angle * Math.PI) / 180; + const x = center + Math.sin(rad) * length; + const y = center - Math.cos(rad) * length; + return ( + + ); + }; + + return ( + + + {ticks} + {hand(hourAngle, center - 14, 2.2, "currentColor", 0.95)} + {hand(minuteAngle, center - 8, 1.6, "currentColor", 0.85)} + {hand(secondAngle, center - 6, 1, "hsl(var(--primary))", 0.95)} + + + ); +} + +export function Clock({ style, time, lang, size = "md" }: ClockProps) { + const resolved = resolveClockStyle(style); + const dayName = formatWeekday(time, lang); + const hijriDate = formatHijri(time, lang); + const gregorianDate = formatDate(time, lang); + const timeNoSec = formatTime(time, lang, { hour: "2-digit", minute: "2-digit" }); + const timeWithSec = formatTime(time, lang, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + const timeFontClass = + size === "sm" + ? "font-mono text-sm font-bold tabular-nums tracking-tight" + : "font-mono text-base sm:text-lg font-bold tabular-nums tracking-tight"; + + if (resolved === "analog") { + return ( +
+ +
+
{timeNoSec}
+
+ {dayName} +
+
+
+ ); + } + + if (resolved === "minimal") { + return ( +
+
{timeNoSec}
+
+ ); + } + + if (resolved === "digital-no-seconds") { + return ( +
+
{timeNoSec}
+
+ {gregorianDate} +
+
+ ); + } + + if (resolved === "digital") { + return ( +
+
{timeWithSec}
+
+ {gregorianDate} +
+
+ ); + } + + // full (default) + return ( +
+
{timeNoSec}
+
+ {dayName} + + {hijriDate} +
+
+ {gregorianDate} +
+
+ ); +} diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index cb696b7c..ae367d0e 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -77,6 +77,16 @@ "services": "الخدمات", "messages": "الرسائل", "alerts": "التنبيهات" + }, + "clockStyle": { + "label": "نمط الساعة", + "options": { + "full": "كامل (الوقت واليوم والتقويمان الهجري والميلادي)", + "digital": "رقمي مع الثواني", + "digital-no-seconds": "رقمي بدون ثواني", + "analog": "ساعة عقاربية", + "minimal": "مبسّط (الوقت فقط)" + } } }, "services": { diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index 4add5209..1ecd3de2 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -77,6 +77,16 @@ "services": "Services", "messages": "Messages", "alerts": "Alerts" + }, + "clockStyle": { + "label": "Clock style", + "options": { + "full": "Full (time, weekday, Hijri & Gregorian)", + "digital": "Digital with seconds", + "digital-no-seconds": "Digital without seconds", + "analog": "Analog clock", + "minimal": "Minimal (time only)" + } } }, "services": { diff --git a/artifacts/teaboy-os/src/pages/home.tsx b/artifacts/teaboy-os/src/pages/home.tsx index 703a5e0a..89dce112 100644 --- a/artifacts/teaboy-os/src/pages/home.tsx +++ b/artifacts/teaboy-os/src/pages/home.tsx @@ -47,13 +47,13 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { - formatTime, formatDate, - formatHijri, 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 { @@ -148,7 +148,7 @@ export default function HomePage() { const [, setLocation] = useLocation(); const { user } = useAuth(); const queryClient = useQueryClient(); - const [time, setTime] = useState(new Date()); + const time = useNow(); const lang = i18nInstance.language; const { data: apps } = useListApps({ query: { queryKey: getListAppsQueryKey() } }); @@ -214,14 +214,7 @@ export default function HomePage() { setLocation(app.route); }; - useEffect(() => { - const timer = setInterval(() => setTime(new Date()), 1000); - return () => clearInterval(timer); - }, []); - - const clock = formatTime(time, lang); const dayName = formatWeekday(time, lang); - const hijriDate = formatHijri(time, lang); const gregorianDate = formatDate(time, lang); const unreadCount = notifications?.filter((n) => !n.isRead).length ?? stats?.unreadNotifications ?? 0; @@ -274,22 +267,13 @@ export default function HomePage() { {/* Clock + dates */} -
-
- {clock} -
-
- {dayName} - - {hijriDate} -
-
- {gregorianDate} -
+
+
{/* Controls */}
+