d0e6912017
Update project name from teaboy-os to tx-os and remove obsolete services. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 3154f23a-748a-4118-aa41-fc01b7b1f04d Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/PVuelRZ Replit-Helium-Checkpoint-Created: true
604 lines
17 KiB
TypeScript
604 lines
17 KiB
TypeScript
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;
|
|
hour12?: boolean | null;
|
|
size?: "sm" | "md";
|
|
};
|
|
|
|
export const DEFAULT_CLOCK_HOUR12 = false;
|
|
|
|
export function resolveClockHour12(value: boolean | null | undefined): boolean {
|
|
return value ?? DEFAULT_CLOCK_HOUR12;
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Classic analog clock widget for the home screen. Latin digits 1-12,
|
|
* 60 minute ticks, double-ring frame, sweeping seconds hand. Uses
|
|
* currentColor so it inherits the foreground color and works in any
|
|
* theme. The `size` prop controls overall pixel diameter.
|
|
*/
|
|
export function AnalogClockWidget({
|
|
time,
|
|
lang,
|
|
hour12,
|
|
className,
|
|
size = 240,
|
|
showLabels = true,
|
|
}: {
|
|
time: Date;
|
|
lang: string;
|
|
hour12?: boolean | null;
|
|
className?: string;
|
|
size?: number;
|
|
showLabels?: boolean;
|
|
}) {
|
|
const use12 = resolveClockHour12(hour12);
|
|
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 cx = size / 2;
|
|
const cy = size / 2;
|
|
const r = cx - 4;
|
|
const scale = size / 240;
|
|
|
|
const DIGITS = [
|
|
"12",
|
|
"1",
|
|
"2",
|
|
"3",
|
|
"4",
|
|
"5",
|
|
"6",
|
|
"7",
|
|
"8",
|
|
"9",
|
|
"10",
|
|
"11",
|
|
];
|
|
|
|
const numeralOffset = Math.max(10, 18 * scale);
|
|
const numeralFont = Math.max(8, 14 * scale);
|
|
const numerals = DIGITS.map((digit, i) => {
|
|
const angle = ((i * 30 - 90) * Math.PI) / 180;
|
|
const numR = r - numeralOffset;
|
|
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={numeralFont}
|
|
fontFamily="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"
|
|
fontWeight="700"
|
|
fill="currentColor"
|
|
opacity={0.9}
|
|
>
|
|
{digit}
|
|
</text>
|
|
);
|
|
});
|
|
|
|
const ticks = Array.from({ length: 60 }, (_, i) => {
|
|
const angle = (i * 6 * Math.PI) / 180;
|
|
const isHour = i % 5 === 0;
|
|
const outer = r - 2 * scale;
|
|
const inner = isHour ? r - 8 * scale : r - 5 * scale;
|
|
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 - 50 * scale, 4.5 * scale, "currentColor", 0.95, 8 * scale)}
|
|
{/* Minute hand */}
|
|
{hand(minuteAngle, r - 22 * scale, 3 * scale, "currentColor", 0.85, 10 * scale)}
|
|
{/* Second hand */}
|
|
{hand(secondAngle, r - 14 * scale, 1.2 * scale, "hsl(var(--primary))", 1, 18 * scale)}
|
|
{/* Center pin */}
|
|
<circle cx={cx} cy={cy} r={4 * scale} fill="currentColor" />
|
|
<circle cx={cx} cy={cy} r={1.5 * scale} fill="hsl(var(--primary))" />
|
|
</svg>
|
|
</div>
|
|
{showLabels && (
|
|
<div className="text-center">
|
|
<div className="font-mono text-sm font-semibold tabular-nums text-foreground tracking-tight">
|
|
{formatTime(time, lang, {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
hour12: use12,
|
|
})}
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground mt-0.5 tabular-nums">
|
|
{formatWeekday(time, lang)} · {formatDate(time, lang)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const HOME_CLOCK_STORAGE_KEY = "tx:home-clock-visible";
|
|
const HOME_CLOCK_EVENT = "tx: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];
|
|
}
|
|
|
|
const TOPBAR_CLOCK_STORAGE_KEY = "tx:topbar-clock-visible";
|
|
const TOPBAR_CLOCK_EVENT = "tx:topbar-clock-changed";
|
|
|
|
function readTopbarClockVisible(): boolean {
|
|
if (typeof window === "undefined") return true;
|
|
try {
|
|
const v = window.localStorage.getItem(TOPBAR_CLOCK_STORAGE_KEY);
|
|
return v === null ? true : v === "1";
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Visibility for the small clock displayed in the home page top bar.
|
|
* Independent of useHomeClockVisibility, which controls the large
|
|
* grid widget on the home screen.
|
|
*/
|
|
export function useTopbarClockVisibility(): [boolean, (next: boolean) => void] {
|
|
const [visible, setVisibleState] = useState<boolean>(readTopbarClockVisible);
|
|
|
|
useEffect(() => {
|
|
const onChange = () => setVisibleState(readTopbarClockVisible());
|
|
window.addEventListener(TOPBAR_CLOCK_EVENT, onChange);
|
|
window.addEventListener("storage", onChange);
|
|
return () => {
|
|
window.removeEventListener(TOPBAR_CLOCK_EVENT, onChange);
|
|
window.removeEventListener("storage", onChange);
|
|
};
|
|
}, []);
|
|
|
|
const setVisible = (next: boolean) => {
|
|
try {
|
|
window.localStorage.setItem(TOPBAR_CLOCK_STORAGE_KEY, next ? "1" : "0");
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
setVisibleState(next);
|
|
window.dispatchEvent(new Event(TOPBAR_CLOCK_EVENT));
|
|
};
|
|
|
|
return [visible, setVisible];
|
|
}
|
|
|
|
const HOME_CLOCK_SIZE_EVENT = "tx:home-clock-size-changed";
|
|
export type HomeClockSize = "compact" | "large";
|
|
|
|
function homeClockSizeKey(userId: number | string | null | undefined): string {
|
|
return userId == null
|
|
? "tx:home-clock-size"
|
|
: `tx:home-clock-size:${userId}`;
|
|
}
|
|
|
|
function readHomeClockSize(userId: number | string | null | undefined): HomeClockSize {
|
|
if (typeof window === "undefined") return "compact";
|
|
try {
|
|
const v = window.localStorage.getItem(homeClockSizeKey(userId));
|
|
return v === "large" ? "large" : "compact";
|
|
} catch {
|
|
return "compact";
|
|
}
|
|
}
|
|
|
|
export function useHomeClockSize(
|
|
userId: number | string | null | undefined,
|
|
): [HomeClockSize, (next: HomeClockSize) => void] {
|
|
const [size, setSizeState] = useState<HomeClockSize>(() => readHomeClockSize(userId));
|
|
useEffect(() => {
|
|
setSizeState(readHomeClockSize(userId));
|
|
const onChange = () => setSizeState(readHomeClockSize(userId));
|
|
window.addEventListener(HOME_CLOCK_SIZE_EVENT, onChange);
|
|
window.addEventListener("storage", onChange);
|
|
return () => {
|
|
window.removeEventListener(HOME_CLOCK_SIZE_EVENT, onChange);
|
|
window.removeEventListener("storage", onChange);
|
|
};
|
|
}, [userId]);
|
|
const setSize = (next: HomeClockSize) => {
|
|
try {
|
|
window.localStorage.setItem(homeClockSizeKey(userId), next);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
setSizeState(next);
|
|
window.dispatchEvent(new Event(HOME_CLOCK_SIZE_EVENT));
|
|
};
|
|
return [size, setSize];
|
|
}
|
|
|
|
const HOME_CLOCK_POS_EVENT = "tx:home-clock-pos-changed";
|
|
|
|
function homeClockPosKey(userId: number | string | null | undefined): string {
|
|
return userId == null
|
|
? "tx:home-clock-position"
|
|
: `tx:home-clock-position:${userId}`;
|
|
}
|
|
|
|
function readHomeClockPosition(userId: number | string | null | undefined): number {
|
|
if (typeof window === "undefined") return -1;
|
|
try {
|
|
const v = window.localStorage.getItem(homeClockPosKey(userId));
|
|
if (v === null) return -1;
|
|
const n = Number(v);
|
|
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : -1;
|
|
} catch {
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
export function useHomeClockPosition(
|
|
userId: number | string | null | undefined,
|
|
): [number, (next: number) => void] {
|
|
const [pos, setPosState] = useState<number>(() => readHomeClockPosition(userId));
|
|
useEffect(() => {
|
|
setPosState(readHomeClockPosition(userId));
|
|
const onChange = () => setPosState(readHomeClockPosition(userId));
|
|
window.addEventListener(HOME_CLOCK_POS_EVENT, onChange);
|
|
window.addEventListener("storage", onChange);
|
|
return () => {
|
|
window.removeEventListener(HOME_CLOCK_POS_EVENT, onChange);
|
|
window.removeEventListener("storage", onChange);
|
|
};
|
|
}, [userId]);
|
|
const setPos = (next: number) => {
|
|
try {
|
|
window.localStorage.setItem(homeClockPosKey(userId), String(next));
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
setPosState(next);
|
|
window.dispatchEvent(new Event(HOME_CLOCK_POS_EVENT));
|
|
};
|
|
return [pos, setPos];
|
|
}
|
|
|
|
export function Clock({ style, time, lang, hour12, size = "md" }: ClockProps) {
|
|
const resolved = resolveClockStyle(style);
|
|
const use12 = resolveClockHour12(hour12);
|
|
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",
|
|
hour12: use12,
|
|
});
|
|
const timeWithSec = formatTime(time, lang, {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
hour12: use12,
|
|
});
|
|
|
|
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>
|
|
);
|
|
}
|