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. Replit-Task-Id: 475a7439-b357-400d-805f-5f2fda20ed24
This commit is contained in:
@@ -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<void> =
|
||||
res.json(buildAuthUser(user, roles));
|
||||
});
|
||||
|
||||
router.patch("/auth/me/clock-style", requireAuth, async (req, res): Promise<void> => {
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -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<AuthUser>(getGetMeQueryKey());
|
||||
if (previous) {
|
||||
queryClient.setQueryData<AuthUser>(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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.clockStyle.label")}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 rounded-lg hover:bg-foreground/5"
|
||||
>
|
||||
<ClockIcon size={18} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-2" align="end">
|
||||
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("home.clockStyle.label")}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 max-h-[60vh] overflow-y-auto">
|
||||
{CLOCK_STYLES.map((style) => {
|
||||
const isActive = style === active;
|
||||
return (
|
||||
<button
|
||||
key={style}
|
||||
type="button"
|
||||
onClick={() => choose(style)}
|
||||
disabled={updateClockStyle.isPending}
|
||||
className={`flex items-center justify-between gap-3 px-2 py-2 rounded-lg text-start hover:bg-foreground/5 transition-colors ${
|
||||
isActive ? "bg-primary/10" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
<Clock style={style} time={now} lang={lang} size="sm" />
|
||||
</div>
|
||||
<div className="text-xs text-foreground font-medium truncate">
|
||||
{t(`home.clockStyle.options.${style}`)}
|
||||
</div>
|
||||
</div>
|
||||
{isActive && <Check size={14} className="text-primary shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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<Date>(() => 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 (
|
||||
<line
|
||||
key={i}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke="currentColor"
|
||||
strokeWidth={i % 3 === 0 ? 1.5 : 1}
|
||||
strokeLinecap="round"
|
||||
opacity={i % 3 === 0 ? 0.85 : 0.45}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<line
|
||||
x1={center}
|
||||
y1={center}
|
||||
x2={x}
|
||||
y2={y}
|
||||
stroke={color}
|
||||
strokeWidth={width}
|
||||
strokeLinecap="round"
|
||||
opacity={opacity}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={px}
|
||||
height={px}
|
||||
viewBox={`0 0 ${px} ${px}`}
|
||||
className="text-foreground shrink-0"
|
||||
role="img"
|
||||
aria-label="Analog clock"
|
||||
>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={center - 1}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1}
|
||||
opacity={0.25}
|
||||
/>
|
||||
{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)}
|
||||
<circle cx={center} cy={center} r={1.6} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-2 text-foreground leading-tight min-w-0">
|
||||
<AnalogClock time={time} size={size} />
|
||||
<div className="flex flex-col items-start min-w-0">
|
||||
<div className={timeFontClass}>{timeNoSec}</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate max-w-[40vw]">
|
||||
{dayName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resolved === "minimal") {
|
||||
return (
|
||||
<div className="flex flex-col items-center text-foreground leading-tight min-w-0">
|
||||
<div className={timeFontClass}>{timeNoSec}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resolved === "digital-no-seconds") {
|
||||
return (
|
||||
<div className="flex flex-col items-center text-foreground leading-tight min-w-0">
|
||||
<div className={timeFontClass}>{timeNoSec}</div>
|
||||
<div className="text-[10px] text-muted-foreground/80 max-w-[60vw] truncate tabular-nums">
|
||||
{gregorianDate}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resolved === "digital") {
|
||||
return (
|
||||
<div className="flex flex-col items-center text-foreground leading-tight min-w-0">
|
||||
<div className={timeFontClass}>{timeWithSec}</div>
|
||||
<div className="text-[10px] text-muted-foreground/80 max-w-[60vw] truncate tabular-nums">
|
||||
{gregorianDate}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// full (default)
|
||||
return (
|
||||
<div className="flex flex-col items-center text-foreground leading-tight min-w-0">
|
||||
<div className={timeFontClass}>{timeNoSec}</div>
|
||||
<div className="text-[10px] text-muted-foreground flex items-center gap-1 mt-0.5 max-w-[60vw] truncate">
|
||||
<span className="truncate">{dayName}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="truncate tabular-nums">{hijriDate}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground/80 max-w-[60vw] truncate tabular-nums">
|
||||
{gregorianDate}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,16 @@
|
||||
"services": "الخدمات",
|
||||
"messages": "الرسائل",
|
||||
"alerts": "التنبيهات"
|
||||
},
|
||||
"clockStyle": {
|
||||
"label": "نمط الساعة",
|
||||
"options": {
|
||||
"full": "كامل (الوقت واليوم والتقويمان الهجري والميلادي)",
|
||||
"digital": "رقمي مع الثواني",
|
||||
"digital-no-seconds": "رقمي بدون ثواني",
|
||||
"analog": "ساعة عقاربية",
|
||||
"minimal": "مبسّط (الوقت فقط)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
{/* Clock + dates */}
|
||||
<div className="flex flex-col items-center text-foreground leading-tight min-w-0 px-2 shrink-0">
|
||||
<div className="font-mono text-base sm:text-lg font-bold tabular-nums tracking-tight">
|
||||
{clock}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground flex items-center gap-1 mt-0.5 max-w-[60vw] truncate">
|
||||
<span className="truncate">{dayName}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="truncate tabular-nums">{hijriDate}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground/80 max-w-[60vw] truncate tabular-nums">
|
||||
{gregorianDate}
|
||||
</div>
|
||||
<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"
|
||||
|
||||
@@ -65,6 +65,24 @@ export interface UpdateLanguageBody {
|
||||
language: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user home-screen clock style preference
|
||||
*/
|
||||
export type ClockStyle = (typeof ClockStyle)[keyof typeof ClockStyle];
|
||||
|
||||
export const ClockStyle = {
|
||||
full: "full",
|
||||
digital: "digital",
|
||||
"digital-no-seconds": "digital-no-seconds",
|
||||
analog: "analog",
|
||||
minimal: "minimal",
|
||||
} as const;
|
||||
|
||||
export interface UpdateClockStyleBody {
|
||||
/** Clock style; pass null to clear and use the default */
|
||||
clockStyle: ClockStyle | null;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -74,6 +92,7 @@ export interface AuthUser {
|
||||
/** @nullable */
|
||||
displayNameEn?: string | null;
|
||||
preferredLanguage: string;
|
||||
clockStyle?: ClockStyle | null;
|
||||
/** @nullable */
|
||||
avatarUrl?: string | null;
|
||||
isActive: boolean;
|
||||
@@ -90,6 +109,7 @@ export interface UserProfile {
|
||||
/** @nullable */
|
||||
displayNameEn?: string | null;
|
||||
preferredLanguage: string;
|
||||
clockStyle?: ClockStyle | null;
|
||||
/** @nullable */
|
||||
avatarUrl?: string | null;
|
||||
isActive: boolean;
|
||||
|
||||
@@ -45,6 +45,7 @@ import type {
|
||||
SuccessResponse,
|
||||
UpdateAppBody,
|
||||
UpdateAppSettingsBody,
|
||||
UpdateClockStyleBody,
|
||||
UpdateLanguageBody,
|
||||
UpdateMyAppOrderBody,
|
||||
UpdateServiceBody,
|
||||
@@ -882,6 +883,92 @@ export const useUpdateLanguage = <
|
||||
return useMutation(getUpdateLanguageMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Update preferred home-screen clock style
|
||||
*/
|
||||
export const getUpdateClockStyleUrl = () => {
|
||||
return `/api/auth/me/clock-style`;
|
||||
};
|
||||
|
||||
export const updateClockStyle = async (
|
||||
updateClockStyleBody: UpdateClockStyleBody,
|
||||
options?: RequestInit,
|
||||
): Promise<AuthUser> => {
|
||||
return customFetch<AuthUser>(getUpdateClockStyleUrl(), {
|
||||
...options,
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(updateClockStyleBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateClockStyleMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateClockStyle>>,
|
||||
TError,
|
||||
{ data: BodyType<UpdateClockStyleBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateClockStyle>>,
|
||||
TError,
|
||||
{ data: BodyType<UpdateClockStyleBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["updateClockStyle"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateClockStyle>>,
|
||||
{ data: BodyType<UpdateClockStyleBody> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return updateClockStyle(data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateClockStyleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateClockStyle>>
|
||||
>;
|
||||
export type UpdateClockStyleMutationBody = BodyType<UpdateClockStyleBody>;
|
||||
export type UpdateClockStyleMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Update preferred home-screen clock style
|
||||
*/
|
||||
export const useUpdateClockStyle = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateClockStyle>>,
|
||||
TError,
|
||||
{ data: BodyType<UpdateClockStyleBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateClockStyle>>,
|
||||
TError,
|
||||
{ data: BodyType<UpdateClockStyleBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateClockStyleMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary List all active apps
|
||||
*/
|
||||
|
||||
@@ -245,6 +245,31 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuthUser"
|
||||
|
||||
/auth/me/clock-style:
|
||||
patch:
|
||||
operationId: updateClockStyle
|
||||
tags: [auth]
|
||||
summary: Update preferred home-screen clock style
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateClockStyleBody"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuthUser"
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
# Apps
|
||||
/apps:
|
||||
get:
|
||||
@@ -970,6 +995,22 @@ components:
|
||||
required:
|
||||
- language
|
||||
|
||||
ClockStyle:
|
||||
type: string
|
||||
enum: [full, digital, digital-no-seconds, analog, minimal]
|
||||
description: Per-user home-screen clock style preference
|
||||
|
||||
UpdateClockStyleBody:
|
||||
type: object
|
||||
properties:
|
||||
clockStyle:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/ClockStyle"
|
||||
- type: "null"
|
||||
description: Clock style; pass null to clear and use the default
|
||||
required:
|
||||
- clockStyle
|
||||
|
||||
AuthUser:
|
||||
type: object
|
||||
properties:
|
||||
@@ -985,6 +1026,10 @@ components:
|
||||
type: ["string", "null"]
|
||||
preferredLanguage:
|
||||
type: string
|
||||
clockStyle:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/ClockStyle"
|
||||
- type: "null"
|
||||
avatarUrl:
|
||||
type: ["string", "null"]
|
||||
isActive:
|
||||
@@ -1020,6 +1065,10 @@ components:
|
||||
type: ["string", "null"]
|
||||
preferredLanguage:
|
||||
type: string
|
||||
clockStyle:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/ClockStyle"
|
||||
- type: "null"
|
||||
avatarUrl:
|
||||
type: ["string", "null"]
|
||||
isActive:
|
||||
|
||||
@@ -43,6 +43,14 @@ export const LoginResponse = zod.object({
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
preferredLanguage: zod.string(),
|
||||
clockStyle: zod
|
||||
.union([
|
||||
zod
|
||||
.enum(["full", "digital", "digital-no-seconds", "analog", "minimal"])
|
||||
.describe("Per-user home-screen clock style preference"),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
isActive: zod.boolean(),
|
||||
roles: zod.array(zod.string()),
|
||||
@@ -114,6 +122,14 @@ export const GetMeResponse = zod.object({
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
preferredLanguage: zod.string(),
|
||||
clockStyle: zod
|
||||
.union([
|
||||
zod
|
||||
.enum(["full", "digital", "digital-no-seconds", "analog", "minimal"])
|
||||
.describe("Per-user home-screen clock style preference"),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
isActive: zod.boolean(),
|
||||
roles: zod.array(zod.string()),
|
||||
@@ -134,6 +150,49 @@ export const UpdateLanguageResponse = zod.object({
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
preferredLanguage: zod.string(),
|
||||
clockStyle: zod
|
||||
.union([
|
||||
zod
|
||||
.enum(["full", "digital", "digital-no-seconds", "analog", "minimal"])
|
||||
.describe("Per-user home-screen clock style preference"),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
isActive: zod.boolean(),
|
||||
roles: zod.array(zod.string()),
|
||||
createdAt: zod.coerce.date(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Update preferred home-screen clock style
|
||||
*/
|
||||
export const UpdateClockStyleBody = zod.object({
|
||||
clockStyle: zod
|
||||
.union([
|
||||
zod
|
||||
.enum(["full", "digital", "digital-no-seconds", "analog", "minimal"])
|
||||
.describe("Per-user home-screen clock style preference"),
|
||||
zod.null(),
|
||||
])
|
||||
.describe("Clock style; pass null to clear and use the default"),
|
||||
});
|
||||
|
||||
export const UpdateClockStyleResponse = zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
email: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
preferredLanguage: zod.string(),
|
||||
clockStyle: zod
|
||||
.union([
|
||||
zod
|
||||
.enum(["full", "digital", "digital-no-seconds", "analog", "minimal"])
|
||||
.describe("Per-user home-screen clock style preference"),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
isActive: zod.boolean(),
|
||||
roles: zod.array(zod.string()),
|
||||
@@ -674,6 +733,14 @@ export const ListUsersResponseItem = zod.object({
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
preferredLanguage: zod.string(),
|
||||
clockStyle: zod
|
||||
.union([
|
||||
zod
|
||||
.enum(["full", "digital", "digital-no-seconds", "analog", "minimal"])
|
||||
.describe("Per-user home-screen clock style preference"),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
isActive: zod.boolean(),
|
||||
roles: zod.array(zod.string()),
|
||||
@@ -711,6 +778,14 @@ export const GetUserResponse = zod.object({
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
preferredLanguage: zod.string(),
|
||||
clockStyle: zod
|
||||
.union([
|
||||
zod
|
||||
.enum(["full", "digital", "digital-no-seconds", "analog", "minimal"])
|
||||
.describe("Per-user home-screen clock style preference"),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
isActive: zod.boolean(),
|
||||
roles: zod.array(zod.string()),
|
||||
@@ -738,6 +813,14 @@ export const UpdateUserResponse = zod.object({
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
preferredLanguage: zod.string(),
|
||||
clockStyle: zod
|
||||
.union([
|
||||
zod
|
||||
.enum(["full", "digital", "digital-no-seconds", "analog", "minimal"])
|
||||
.describe("Per-user home-screen clock style preference"),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
isActive: zod.boolean(),
|
||||
roles: zod.array(zod.string()),
|
||||
|
||||
@@ -10,6 +10,7 @@ export const usersTable = pgTable("users", {
|
||||
displayNameAr: varchar("display_name_ar", { length: 200 }),
|
||||
displayNameEn: varchar("display_name_en", { length: 200 }),
|
||||
preferredLanguage: varchar("preferred_language", { length: 10 }).notNull().default("ar"),
|
||||
clockStyle: varchar("clock_style", { length: 30 }),
|
||||
avatarUrl: text("avatar_url"),
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
- **Arabic default** with full RTL layout; English toggle persisted in localStorage + user profile
|
||||
- **Glassmorphism OS UI**: animated gradient background, frosted glass panels
|
||||
- **OS Home Screen**: live clock status bar, app grid, bottom dock
|
||||
- **OS Home Screen**: live clock status bar (per-user clock style: full / digital / digital-no-seconds / analog / minimal, picker in status bar), app grid, bottom dock
|
||||
- **خدماتي (My Services)**: service card grid with availability status
|
||||
- **Internal Chat**: real-time messages via Socket.IO, conversation list
|
||||
- **Notifications**: unread tracking, mark-all-read
|
||||
|
||||
Reference in New Issue
Block a user