Add a classic analog clock widget with customizable visibility
Adds a new `AnalogClockWidget` component with Roman numerals and a sweeping second hand, integrated into the home page. The widget's visibility can now be toggled via the `ClockStylePicker` and is persisted in `localStorage`. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 0a1df300-b156-461b-90d5-e9874f25113f Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/YPEna1J Replit-Helium-Checkpoint-Created: true
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -7,13 +7,19 @@ import {
|
||||
type AuthUser,
|
||||
type ClockStyle as ClockStyleType,
|
||||
} from "@workspace/api-client-react";
|
||||
import { Clock as ClockIcon, Check } from "lucide-react";
|
||||
import { Clock as ClockIcon, Check, Eye, EyeOff } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Clock, CLOCK_STYLES, resolveClockStyle, useNow } from "@/components/clock";
|
||||
import {
|
||||
Clock,
|
||||
CLOCK_STYLES,
|
||||
resolveClockStyle,
|
||||
useNow,
|
||||
useHomeClockVisibility,
|
||||
} from "@/components/clock";
|
||||
|
||||
type Props = {
|
||||
currentStyle: ClockStyleType | null | undefined;
|
||||
@@ -28,6 +34,7 @@ export function ClockStylePicker({ currentStyle }: Props) {
|
||||
|
||||
const updateClockStyle = useUpdateClockStyle();
|
||||
const active = resolveClockStyle(currentStyle);
|
||||
const [homeClockVisible, setHomeClockVisible] = useHomeClockVisibility();
|
||||
|
||||
const choose = (style: ClockStyleType) => {
|
||||
const previous = queryClient.getQueryData<AuthUser>(getGetMeQueryKey());
|
||||
@@ -67,6 +74,37 @@ export function ClockStylePicker({ currentStyle }: Props) {
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-2" align="end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHomeClockVisible(!homeClockVisible)}
|
||||
className="w-full flex items-center justify-between gap-3 px-2 py-2 rounded-lg text-start hover:bg-foreground/5 transition-colors mb-1"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{homeClockVisible ? (
|
||||
<Eye size={16} className="text-primary shrink-0" />
|
||||
) : (
|
||||
<EyeOff size={16} className="text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="text-xs text-foreground font-medium truncate">
|
||||
{t("home.clockStyle.showWidget")}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
|
||||
homeClockVisible ? "bg-primary" : "bg-foreground/15"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
||||
homeClockVisible
|
||||
? "translate-x-[18px] rtl:-translate-x-[18px]"
|
||||
: "translate-x-0.5 rtl:-translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
<div className="h-px bg-foreground/10 mx-1 mb-1" />
|
||||
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("home.clockStyle.label")}
|
||||
</div>
|
||||
|
||||
@@ -119,6 +119,252 @@ function AnalogClock({ time, size = "md" }: { time: Date; size?: "sm" | "md" })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Large, classic analog clock widget for the home screen.
|
||||
* Roman numerals, 60 minute ticks, double-ring frame, sweeping
|
||||
* seconds hand. Uses currentColor so it inherits the foreground
|
||||
* color and works in any theme.
|
||||
*/
|
||||
export function AnalogClockWidget({
|
||||
time,
|
||||
lang,
|
||||
className,
|
||||
}: {
|
||||
time: Date;
|
||||
lang: string;
|
||||
className?: string;
|
||||
}) {
|
||||
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 size = 240;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = cx - 8;
|
||||
|
||||
const ROMAN = [
|
||||
"XII",
|
||||
"I",
|
||||
"II",
|
||||
"III",
|
||||
"IV",
|
||||
"V",
|
||||
"VI",
|
||||
"VII",
|
||||
"VIII",
|
||||
"IX",
|
||||
"X",
|
||||
"XI",
|
||||
];
|
||||
|
||||
const numerals = ROMAN.map((roman, i) => {
|
||||
const angle = ((i * 30 - 90) * Math.PI) / 180;
|
||||
const numR = r - 22;
|
||||
const x = cx + Math.cos(angle) * numR;
|
||||
const y = cy + Math.sin(angle) * numR;
|
||||
return (
|
||||
<text
|
||||
key={i}
|
||||
x={x}
|
||||
y={y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="15"
|
||||
fontFamily="Georgia, 'Times New Roman', serif"
|
||||
fontWeight="600"
|
||||
fill="currentColor"
|
||||
opacity={0.85}
|
||||
>
|
||||
{roman}
|
||||
</text>
|
||||
);
|
||||
});
|
||||
|
||||
const ticks = Array.from({ length: 60 }, (_, i) => {
|
||||
const angle = (i * 6 * Math.PI) / 180;
|
||||
const isHour = i % 5 === 0;
|
||||
const outer = r - 4;
|
||||
const inner = isHour ? r - 11 : r - 7;
|
||||
const x1 = cx + Math.sin(angle) * inner;
|
||||
const y1 = cy - Math.cos(angle) * inner;
|
||||
const x2 = cx + Math.sin(angle) * outer;
|
||||
const y2 = cy - Math.cos(angle) * outer;
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke="currentColor"
|
||||
strokeWidth={isHour ? 1.8 : 0.7}
|
||||
strokeLinecap="round"
|
||||
opacity={isHour ? 0.85 : 0.35}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const hand = (
|
||||
angle: number,
|
||||
length: number,
|
||||
width: number,
|
||||
color: string,
|
||||
opacity = 1,
|
||||
tailLength = 0,
|
||||
) => {
|
||||
const rad = (angle * Math.PI) / 180;
|
||||
const x = cx + Math.sin(rad) * length;
|
||||
const y = cy - Math.cos(rad) * length;
|
||||
const tx = cx - Math.sin(rad) * tailLength;
|
||||
const ty = cy + Math.cos(rad) * tailLength;
|
||||
return (
|
||||
<line
|
||||
x1={tx}
|
||||
y1={ty}
|
||||
x2={x}
|
||||
y2={y}
|
||||
stroke={color}
|
||||
strokeWidth={width}
|
||||
strokeLinecap="round"
|
||||
opacity={opacity}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center gap-3 ${className ?? ""}`}
|
||||
dir="ltr"
|
||||
>
|
||||
<div className="relative drop-shadow-sm">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="text-foreground"
|
||||
role="img"
|
||||
aria-label="Analog clock"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="tbo-clock-face" cx="50%" cy="38%" r="65%">
|
||||
<stop offset="0%" stopColor="white" stopOpacity="0.95" />
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0.55" />
|
||||
</radialGradient>
|
||||
<radialGradient id="tbo-clock-bezel" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="85%" stopColor="currentColor" stopOpacity="0" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0.2" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
{/* Outer bezel ring */}
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r + 6}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.2}
|
||||
opacity={0.25}
|
||||
/>
|
||||
{/* Face */}
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r + 2}
|
||||
fill="url(#tbo-clock-face)"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1}
|
||||
opacity={0.9}
|
||||
/>
|
||||
<circle cx={cx} cy={cy} r={r + 2} fill="url(#tbo-clock-bezel)" />
|
||||
{/* Inner ring */}
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r - 14}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={0.5}
|
||||
opacity={0.18}
|
||||
/>
|
||||
{ticks}
|
||||
{numerals}
|
||||
{/* Hour hand */}
|
||||
{hand(hourAngle, r - 60, 4.5, "currentColor", 0.95, 8)}
|
||||
{/* Minute hand */}
|
||||
{hand(minuteAngle, r - 28, 3, "currentColor", 0.85, 10)}
|
||||
{/* Second hand */}
|
||||
{hand(secondAngle, r - 18, 1.2, "hsl(var(--primary))", 1, 22)}
|
||||
{/* Center pin */}
|
||||
<circle cx={cx} cy={cy} r={5.5} fill="currentColor" />
|
||||
<circle cx={cx} cy={cy} r={2} fill="hsl(var(--primary))" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-mono text-base font-semibold tabular-nums text-foreground tracking-tight">
|
||||
{formatTime(time, lang, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
|
||||
{formatWeekday(time, lang)} · {formatDate(time, lang)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HOME_CLOCK_STORAGE_KEY = "teaboy:home-clock-visible";
|
||||
const HOME_CLOCK_EVENT = "teaboy:home-clock-changed";
|
||||
|
||||
function readHomeClockVisible(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
try {
|
||||
const v = window.localStorage.getItem(HOME_CLOCK_STORAGE_KEY);
|
||||
return v === null ? true : v === "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small hook to read & persist whether the big home-screen analog
|
||||
* clock widget is visible. Persists in localStorage and broadcasts
|
||||
* a custom event so multiple consumers stay in sync within the same
|
||||
* tab (storage events only fire across tabs).
|
||||
*/
|
||||
export function useHomeClockVisibility(): [boolean, (next: boolean) => void] {
|
||||
const [visible, setVisibleState] = useState<boolean>(readHomeClockVisible);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setVisibleState(readHomeClockVisible());
|
||||
window.addEventListener(HOME_CLOCK_EVENT, onChange);
|
||||
window.addEventListener("storage", onChange);
|
||||
return () => {
|
||||
window.removeEventListener(HOME_CLOCK_EVENT, onChange);
|
||||
window.removeEventListener("storage", onChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setVisible = (next: boolean) => {
|
||||
try {
|
||||
window.localStorage.setItem(HOME_CLOCK_STORAGE_KEY, next ? "1" : "0");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setVisibleState(next);
|
||||
window.dispatchEvent(new Event(HOME_CLOCK_EVENT));
|
||||
};
|
||||
|
||||
return [visible, setVisible];
|
||||
}
|
||||
|
||||
export function Clock({ style, time, lang, size = "md" }: ClockProps) {
|
||||
const resolved = resolveClockStyle(style);
|
||||
const dayName = formatWeekday(time, lang);
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
},
|
||||
"clockStyle": {
|
||||
"label": "نمط الساعة",
|
||||
"showWidget": "إظهار ساعة الحائط في الشاشة الرئيسية",
|
||||
"options": {
|
||||
"full": "كامل (الوقت واليوم والتقويمان الهجري والميلادي)",
|
||||
"digital": "رقمي مع الثواني",
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
},
|
||||
"clockStyle": {
|
||||
"label": "Clock style",
|
||||
"showWidget": "Show wall clock on home screen",
|
||||
"options": {
|
||||
"full": "Full (time, weekday, Hijri & Gregorian)",
|
||||
"digital": "Digital with seconds",
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
formatNumber,
|
||||
greetingKey,
|
||||
} from "@/lib/i18n-format";
|
||||
import { Clock, useNow } from "@/components/clock";
|
||||
import { Clock, useNow, AnalogClockWidget, useHomeClockVisibility } from "@/components/clock";
|
||||
import { ClockStylePicker } from "@/components/clock-style-picker";
|
||||
|
||||
type IconName = keyof typeof LucideIcons;
|
||||
@@ -226,6 +226,7 @@ export default function HomePage() {
|
||||
const isAdmin = user?.roles?.includes("admin") ?? false;
|
||||
|
||||
const greeting = t(`home.greeting.${greetingKey(time)}`, { name: displayName });
|
||||
const [homeClockVisible] = useHomeClockVisibility();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout.mutate(undefined, {
|
||||
@@ -303,14 +304,21 @@ export default function HomePage() {
|
||||
|
||||
{/* 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>
|
||||
{/* Greeting + optional analog clock widget */}
|
||||
<div className="mb-8 flex items-start justify-between gap-6 flex-wrap">
|
||||
<div className="max-w-3xl min-w-0">
|
||||
<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>
|
||||
{homeClockVisible && (
|
||||
<div className="hidden md:block shrink-0">
|
||||
<AnalogClockWidget time={time} lang={lang} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Row — admin only */}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 412 KiB |
Reference in New Issue
Block a user