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)} + /> + ); + })}