diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg
index afb1dfdb..e444e8e2 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.tsx b/artifacts/teaboy-os/src/components/clock.tsx
index 7050850e..f1f2a7f4 100644
--- a/artifacts/teaboy-os/src/components/clock.tsx
+++ b/artifacts/teaboy-os/src/components/clock.tsx
@@ -127,21 +127,25 @@ 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.
+ * 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;
@@ -152,29 +156,31 @@ export function AnalogClockWidget({
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 r = cx - 4;
+ const scale = size / 240;
- const ROMAN = [
- "XII",
- "I",
- "II",
- "III",
- "IV",
- "V",
- "VI",
- "VII",
- "VIII",
- "IX",
- "X",
- "XI",
+ const DIGITS = [
+ "12",
+ "1",
+ "2",
+ "3",
+ "4",
+ "5",
+ "6",
+ "7",
+ "8",
+ "9",
+ "10",
+ "11",
];
- const numerals = ROMAN.map((roman, i) => {
+ 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 - 22;
+ const numR = r - numeralOffset;
const x = cx + Math.cos(angle) * numR;
const y = cy + Math.sin(angle) * numR;
return (
@@ -184,13 +190,13 @@ export function AnalogClockWidget({
y={y}
textAnchor="middle"
dominantBaseline="central"
- fontSize="15"
- fontFamily="Georgia, 'Times New Roman', serif"
- fontWeight="600"
+ fontSize={numeralFont}
+ fontFamily="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"
+ fontWeight="700"
fill="currentColor"
- opacity={0.85}
+ opacity={0.9}
>
- {roman}
+ {digit}
);
});
@@ -198,8 +204,8 @@ export function AnalogClockWidget({
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 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;
@@ -304,29 +310,31 @@ export function AnalogClockWidget({
{ticks}
{numerals}
{/* Hour hand */}
- {hand(hourAngle, r - 60, 4.5, "currentColor", 0.95, 8)}
+ {hand(hourAngle, r - 50 * scale, 4.5 * scale, "currentColor", 0.95, 8 * scale)}
{/* Minute hand */}
- {hand(minuteAngle, r - 28, 3, "currentColor", 0.85, 10)}
+ {hand(minuteAngle, r - 22 * scale, 3 * scale, "currentColor", 0.85, 10 * scale)}
{/* Second hand */}
- {hand(secondAngle, r - 18, 1.2, "hsl(var(--primary))", 1, 22)}
+ {hand(secondAngle, r - 14 * scale, 1.2 * scale, "hsl(var(--primary))", 1, 18 * scale)}
{/* Center pin */}
-
-
+
+
-
-
- {formatTime(time, lang, {
- hour: "2-digit",
- minute: "2-digit",
- second: "2-digit",
- hour12: use12,
- })}
+ {showLabels && (
+
+
+ {formatTime(time, lang, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: use12,
+ })}
+
+
+ {formatWeekday(time, lang)} · {formatDate(time, lang)}
+
-
- {formatWeekday(time, lang)} · {formatDate(time, lang)}
-
-
+ )}
);
}
@@ -376,6 +384,81 @@ export function useHomeClockVisibility(): [boolean, (next: boolean) => void] {
return [visible, setVisible];
}
+const HOME_CLOCK_SIZE_KEY = "teaboy:home-clock-size";
+const HOME_CLOCK_SIZE_EVENT = "teaboy:home-clock-size-changed";
+export type HomeClockSize = "compact" | "large";
+
+function readHomeClockSize(): HomeClockSize {
+ if (typeof window === "undefined") return "compact";
+ try {
+ const v = window.localStorage.getItem(HOME_CLOCK_SIZE_KEY);
+ return v === "large" ? "large" : "compact";
+ } catch {
+ return "compact";
+ }
+}
+
+export function useHomeClockSize(): [HomeClockSize, (next: HomeClockSize) => void] {
+ const [size, setSizeState] = useState(readHomeClockSize);
+ useEffect(() => {
+ const onChange = () => setSizeState(readHomeClockSize());
+ window.addEventListener(HOME_CLOCK_SIZE_EVENT, onChange);
+ window.addEventListener("storage", onChange);
+ return () => {
+ window.removeEventListener(HOME_CLOCK_SIZE_EVENT, onChange);
+ window.removeEventListener("storage", onChange);
+ };
+ }, []);
+ const setSize = (next: HomeClockSize) => {
+ try {
+ window.localStorage.setItem(HOME_CLOCK_SIZE_KEY, next);
+ } catch {
+ /* ignore */
+ }
+ setSizeState(next);
+ window.dispatchEvent(new Event(HOME_CLOCK_SIZE_EVENT));
+ };
+ return [size, setSize];
+}
+
+const HOME_CLOCK_POS_KEY = "teaboy:home-clock-position";
+const HOME_CLOCK_POS_EVENT = "teaboy:home-clock-pos-changed";
+
+function readHomeClockPosition(): number {
+ if (typeof window === "undefined") return 0;
+ try {
+ const v = window.localStorage.getItem(HOME_CLOCK_POS_KEY);
+ if (v === null) return 0;
+ const n = Number(v);
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
+ } catch {
+ return 0;
+ }
+}
+
+export function useHomeClockPosition(): [number, (next: number) => void] {
+ const [pos, setPosState] = useState(readHomeClockPosition);
+ useEffect(() => {
+ const onChange = () => setPosState(readHomeClockPosition());
+ window.addEventListener(HOME_CLOCK_POS_EVENT, onChange);
+ window.addEventListener("storage", onChange);
+ return () => {
+ window.removeEventListener(HOME_CLOCK_POS_EVENT, onChange);
+ window.removeEventListener("storage", onChange);
+ };
+ }, []);
+ const setPos = (next: number) => {
+ try {
+ window.localStorage.setItem(HOME_CLOCK_POS_KEY, 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);
diff --git a/artifacts/teaboy-os/src/pages/home.tsx b/artifacts/teaboy-os/src/pages/home.tsx
index ac9d7809..128bb73c 100644
--- a/artifacts/teaboy-os/src/pages/home.tsx
+++ b/artifacts/teaboy-os/src/pages/home.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useMemo } from "react";
+import { useState, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import {
@@ -48,11 +48,20 @@ import {
import { CSS } from "@dnd-kit/utilities";
import {
formatDate,
+ formatTime,
formatWeekday,
formatNumber,
greetingKey,
} from "@/lib/i18n-format";
-import { Clock, useNow, AnalogClockWidget, useHomeClockVisibility } from "@/components/clock";
+import {
+ Clock,
+ useNow,
+ AnalogClockWidget,
+ useHomeClockVisibility,
+ useHomeClockSize,
+ useHomeClockPosition,
+ type HomeClockSize,
+} from "@/components/clock";
import { ClockStylePicker } from "@/components/clock-style-picker";
type IconName = keyof typeof LucideIcons;
@@ -99,7 +108,7 @@ function AppIconContent({ app }: { app: { nameAr: string; nameEn: string; iconNa
}
function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) {
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: app.id });
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `app-${app.id}` });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
@@ -122,6 +131,111 @@ function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) {
);
}
+const CLOCK_TILE_ID = "__clock";
+
+function SortableClockTile({
+ time,
+ lang,
+ hour12,
+ size,
+ onToggleSize,
+}: {
+ time: Date;
+ lang: string;
+ hour12: boolean | null | undefined;
+ size: HomeClockSize;
+ onToggleSize: () => void;
+}) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: CLOCK_TILE_ID,
+ });
+ const style: React.CSSProperties = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.6 : 1,
+ zIndex: isDragging ? 20 : "auto",
+ touchAction: "none",
+ };
+
+ const longPressTimer = useRef(null);
+ const startPos = useRef<{ x: number; y: number } | null>(null);
+
+ const clearTimer = () => {
+ if (longPressTimer.current !== null) {
+ window.clearTimeout(longPressTimer.current);
+ longPressTimer.current = null;
+ }
+ };
+
+ const dndPointerDown = listeners?.onPointerDown as
+ | ((e: React.PointerEvent) => void)
+ | undefined;
+
+ const onPointerDown = (e: React.PointerEvent) => {
+ startPos.current = { x: e.clientX, y: e.clientY };
+ clearTimer();
+ longPressTimer.current = window.setTimeout(() => {
+ onToggleSize();
+ }, 500);
+ dndPointerDown?.(e);
+ };
+ const onPointerMove = (e: React.PointerEvent) => {
+ if (!startPos.current) return;
+ const dx = e.clientX - startPos.current.x;
+ const dy = e.clientY - startPos.current.y;
+ if (dx * dx + dy * dy > 64) clearTimer();
+ };
+ const onPointerUp = () => {
+ clearTimer();
+ startPos.current = null;
+ };
+
+ const isLarge = size === "large";
+ const px = isLarge ? 168 : 78;
+
+ const { onPointerDown: _ignored, ...restListeners } = listeners ?? {};
+
+ return (
+
+
+
+ {formatTime(time, lang, {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: resolveClockHour12Bool(hour12),
+ })}
+
+
+ );
+}
+
+function resolveClockHour12Bool(v: boolean | null | undefined): boolean {
+ return v ?? false;
+}
+
type StatCardProps = {
label: string;
value: string;
@@ -174,17 +288,42 @@ export default function HomePage() {
useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }),
);
+ const [clockSize, setClockSize] = useHomeClockSize();
+ const [clockPos, setClockPos] = useHomeClockPosition();
+ const toggleClockSize = () => setClockSize(clockSize === "large" ? "compact" : "large");
+
+ const combinedIds = useMemo(() => {
+ const appIds = (orderedApps ?? []).map((a) => `app-${a.id}`);
+ if (!homeClockVisible) return appIds;
+ const safePos = Math.min(Math.max(clockPos, 0), appIds.length);
+ const out: string[] = [...appIds];
+ out.splice(safePos, 0, CLOCK_TILE_ID);
+ return out;
+ }, [orderedApps, clockPos, homeClockVisible]);
+ const sortableIds = combinedIds;
+
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);
+ const oldIndex = combinedIds.findIndex((id) => id === active.id);
+ const newIndex = combinedIds.findIndex((id) => id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
+ const nextCombined = arrayMove(combinedIds, oldIndex, newIndex);
+ const newClockPos = nextCombined.findIndex((id) => id === CLOCK_TILE_ID);
+ if (newClockPos !== clockPos) setClockPos(newClockPos);
+ const newAppOrder = nextCombined
+ .filter((id) => id !== CLOCK_TILE_ID)
+ .map((id) => Number(String(id).slice(4)))
+ .map((id) => orderedApps.find((a) => a.id === id))
+ .filter((a): a is App => a !== undefined);
+ const sameAppOrder =
+ newAppOrder.length === orderedApps.length &&
+ newAppOrder.every((a, i) => a.id === orderedApps[i].id);
+ if (sameAppOrder) return;
const previous = orderedApps;
- const next = arrayMove(orderedApps, oldIndex, newIndex);
- setOrderedApps(next);
+ setOrderedApps(newAppOrder);
updateOrder.mutate(
- { data: { order: next.map((a) => a.id) } },
+ { data: { order: newAppOrder.map((a) => a.id) } },
{
onSuccess: (data) => {
setOrderedApps(data);
@@ -198,7 +337,6 @@ export default function HomePage() {
);
};
- const sortableIds = useMemo(() => (orderedApps ?? []).map((a) => a.id), [orderedApps]);
const { data: stats } = useGetHomeStats({ query: { queryKey: getGetHomeStatsQueryKey() } });
const { data: notifications } = useListNotifications({ query: { queryKey: getListNotificationsQueryKey() } });
@@ -313,7 +451,7 @@ export default function HomePage() {
{/* Main Content */}
{/* Greeting + optional analog clock widget */}
-
+
{greeting}
@@ -322,15 +460,6 @@ export default function HomePage() {
{dayName} · {gregorianDate}
- {homeClockVisible && (
-
- )}
{/* Stats Row — admin only */}
@@ -387,14 +516,32 @@ export default function HomePage() {
) : (
-
- {orderedApps?.map((app) => (
-
openApp(app)}
- />
- ))}
+
+ {combinedIds.map((id) => {
+ if (id === CLOCK_TILE_ID) {
+ if (!homeClockVisible) return null;
+ return (
+
+ );
+ }
+ const appId = Number(String(id).slice(4));
+ const app = orderedApps?.find((a) => a.id === appId);
+ if (!app) return null;
+ return (
+ openApp(app)}
+ />
+ );
+ })}