From dc20ea0885f767ace6167119def6ebe37e429f55 Mon Sep 17 00:00:00 2001 From: Mateus Date: Mon, 23 Mar 2026 09:45:48 -0300 Subject: [PATCH] feat: add draggable desktop widgets (clock, calendar, weather, youtube) Introduce a widget layer on the Desktop with four draggable widgets: - ClockWidget: live clock with 1s tick using Intl.DateTimeFormat - CalendarWidget: interactive month grid with navigation (uses existing createCalendar) - WeatherWidget: current weather from wttr.in with 30min auto-refresh and emoji mapping - YouTubeWidget: embedded YouTube player with a generic public video Shared infrastructure includes a base StyledWidget styled-component with backdrop blur/border/shadow, and a useDraggableWidget hook that supports mouse-based drag constrained within the viewport (respecting TASKBAR_HEIGHT). A WidgetManager component orchestrates all widgets and is rendered inside the Desktop component after the FileManager. Made-with: Cursor --- .../CalendarWidget/StyledCalendarWidget.ts | 85 +++++++++++++ .../Desktop/Widgets/CalendarWidget/index.tsx | 94 ++++++++++++++ .../Widgets/ClockWidget/StyledClockWidget.ts | 22 ++++ .../Desktop/Widgets/ClockWidget/index.tsx | 61 +++++++++ .../system/Desktop/Widgets/StyledWidget.ts | 23 ++++ .../WeatherWidget/StyledWeatherWidget.ts | 48 +++++++ .../Desktop/Widgets/WeatherWidget/index.tsx | 48 +++++++ .../Widgets/WeatherWidget/useWeather.ts | 112 +++++++++++++++++ .../YouTubeWidget/StyledYouTubeWidget.ts | 19 +++ .../Desktop/Widgets/YouTubeWidget/index.tsx | 35 ++++++ components/system/Desktop/Widgets/index.tsx | 21 ++++ .../Desktop/Widgets/useDraggableWidget.ts | 117 ++++++++++++++++++ components/system/Desktop/index.tsx | 2 + 13 files changed, 687 insertions(+) create mode 100644 components/system/Desktop/Widgets/CalendarWidget/StyledCalendarWidget.ts create mode 100644 components/system/Desktop/Widgets/CalendarWidget/index.tsx create mode 100644 components/system/Desktop/Widgets/ClockWidget/StyledClockWidget.ts create mode 100644 components/system/Desktop/Widgets/ClockWidget/index.tsx create mode 100644 components/system/Desktop/Widgets/StyledWidget.ts create mode 100644 components/system/Desktop/Widgets/WeatherWidget/StyledWeatherWidget.ts create mode 100644 components/system/Desktop/Widgets/WeatherWidget/index.tsx create mode 100644 components/system/Desktop/Widgets/WeatherWidget/useWeather.ts create mode 100644 components/system/Desktop/Widgets/YouTubeWidget/StyledYouTubeWidget.ts create mode 100644 components/system/Desktop/Widgets/YouTubeWidget/index.tsx create mode 100644 components/system/Desktop/Widgets/index.tsx create mode 100644 components/system/Desktop/Widgets/useDraggableWidget.ts diff --git a/components/system/Desktop/Widgets/CalendarWidget/StyledCalendarWidget.ts b/components/system/Desktop/Widgets/CalendarWidget/StyledCalendarWidget.ts new file mode 100644 index 0000000000..5667c5067b --- /dev/null +++ b/components/system/Desktop/Widgets/CalendarWidget/StyledCalendarWidget.ts @@ -0,0 +1,85 @@ +import styled from "styled-components"; +import StyledWidget from "components/system/Desktop/Widgets/StyledWidget"; + +const StyledCalendarWidget = styled(StyledWidget)` + min-width: 280px; + + .calendar-nav { + align-items: center; + display: flex; + gap: 8px; + justify-content: space-between; + padding: 4px 14px 8px; + } + + .calendar-month { + font-size: 14px; + font-weight: 500; + } + + .calendar-nav-buttons { + display: flex; + gap: 4px; + } + + .calendar-nav-buttons button { + background: none; + border: 0; + border-radius: 4px; + color: ${({ theme }) => theme.colors.text}; + cursor: pointer; + font-size: 14px; + height: 28px; + line-height: 28px; + padding: 0; + width: 28px; + + &:hover { + background-color: ${({ theme }) => theme.colors.taskbar.hover}; + } + + &:active { + background-color: ${({ theme }) => theme.colors.taskbar.foreground}; + } + } + + table { + border-collapse: collapse; + padding: 0 8px 10px; + width: 100%; + } + + th { + color: ${({ theme }) => theme.colors.text}; + font-size: 11px; + font-weight: 400; + opacity: 50%; + padding: 4px 0; + text-align: center; + width: 40px; + } + + td { + border-radius: 50%; + font-size: 12px; + height: 32px; + text-align: center; + width: 40px; + + &.prev, + &.next { + opacity: 30%; + } + + &.today { + background-color: rgb(0 120 215); + font-weight: 600; + } + + &:not(.today, .prev, .next):hover { + background-color: ${({ theme }) => theme.colors.taskbar.hover}; + } + } +`; + +export default StyledCalendarWidget; diff --git a/components/system/Desktop/Widgets/CalendarWidget/index.tsx b/components/system/Desktop/Widgets/CalendarWidget/index.tsx new file mode 100644 index 0000000000..dcb9c148f1 --- /dev/null +++ b/components/system/Desktop/Widgets/CalendarWidget/index.tsx @@ -0,0 +1,94 @@ +import { memo, useCallback, useMemo, useState } from "react"; +import StyledCalendarWidget from "components/system/Desktop/Widgets/CalendarWidget/StyledCalendarWidget"; +import { + type Calendar, + createCalendar, +} from "components/system/Taskbar/Calendar/functions"; +import useDraggableWidget, { + type Position, +} from "components/system/Desktop/Widgets/useDraggableWidget"; + +const DAY_NAMES = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; + +type CalendarWidgetProps = { + defaultPosition: Position; +}; + +const CalendarWidget: FC = ({ defaultPosition }) => { + const [date, setDate] = useState(() => new Date()); + const [calendar, setCalendar] = useState(() => + createCalendar(date) + ); + const { dragHandleProps, style } = useDraggableWidget(defaultPosition); + const today = useMemo(() => new Date(), []); + + const isCurrentDate = useMemo( + () => + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear(), + [date, today] + ); + + const changeMonth = useCallback( + (direction: number): void => { + const newDate = new Date(date); + const newMonth = newDate.getMonth() + direction; + + newDate.setDate(1); + newDate.setMonth(newMonth); + + const resolvedMonth = + newMonth === 12 ? 0 : newMonth === -1 ? 11 : newMonth; + const isCurrentMonth = resolvedMonth === today.getMonth(); + + if (isCurrentMonth) newDate.setDate(today.getDate()); + + setDate(newDate); + setCalendar(createCalendar(newDate)); + }, + [date, today] + ); + + const monthLabel = `${date.toLocaleString("en-US", { month: "long" })}, ${date.getFullYear()}`; + + return ( + +
+ Calendar +
+
+ {monthLabel} +
+ + +
+
+ + + + {DAY_NAMES.map((dayName) => ( + + ))} + + + + {calendar.map((week) => ( + + {week.map(([day, type]) => ( + + ))} + + ))} + +
{dayName}
+ {day} +
+
+ ); +}; + +export default memo(CalendarWidget); diff --git a/components/system/Desktop/Widgets/ClockWidget/StyledClockWidget.ts b/components/system/Desktop/Widgets/ClockWidget/StyledClockWidget.ts new file mode 100644 index 0000000000..584a769a11 --- /dev/null +++ b/components/system/Desktop/Widgets/ClockWidget/StyledClockWidget.ts @@ -0,0 +1,22 @@ +import styled from "styled-components"; +import StyledWidget from "components/system/Desktop/Widgets/StyledWidget"; + +const StyledClockWidget = styled(StyledWidget)` + min-width: 220px; + + .clock-time { + font-size: 48px; + font-weight: 200; + letter-spacing: -1px; + line-height: 1; + padding: 6px 14px 0; + } + + .clock-date { + font-size: 13px; + opacity: 80%; + padding: 6px 14px 14px; + } +`; + +export default StyledClockWidget; diff --git a/components/system/Desktop/Widgets/ClockWidget/index.tsx b/components/system/Desktop/Widgets/ClockWidget/index.tsx new file mode 100644 index 0000000000..dd88dce085 --- /dev/null +++ b/components/system/Desktop/Widgets/ClockWidget/index.tsx @@ -0,0 +1,61 @@ +import { memo, useCallback, useEffect, useState } from "react"; +import StyledClockWidget from "components/system/Desktop/Widgets/ClockWidget/StyledClockWidget"; +import useDraggableWidget, { + type Position, +} from "components/system/Desktop/Widgets/useDraggableWidget"; +import { MILLISECONDS_IN_SECOND } from "utils/constants"; + +const DEFAULT_LOCALE = "en"; + +const timeFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { + hour: "numeric", + hour12: true, + minute: "2-digit", +}); + +const dateFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { + day: "numeric", + month: "long", + weekday: "long", + year: "numeric", +}); + +type ClockWidgetProps = { + defaultPosition: Position; +}; + +const formatTime = (now: Date): string => timeFormatter.format(now); +const formatDate = (now: Date): string => dateFormatter.format(now); + +const ClockWidget: FC = ({ defaultPosition }) => { + const [now, setNow] = useState(() => new Date()); + const { dragHandleProps, style } = useDraggableWidget(defaultPosition); + + const tick = useCallback((): void => { + setNow(new Date()); + }, []); + + useEffect(() => { + const timeout = setTimeout(() => { + tick(); + const interval = setInterval(tick, MILLISECONDS_IN_SECOND); + + return (): void => clearInterval(interval); + }, MILLISECONDS_IN_SECOND - now.getMilliseconds()); + + return (): void => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tick]); + + return ( + +
+ Clock +
+
{formatTime(now)}
+
{formatDate(now)}
+
+ ); +}; + +export default memo(ClockWidget); diff --git a/components/system/Desktop/Widgets/StyledWidget.ts b/components/system/Desktop/Widgets/StyledWidget.ts new file mode 100644 index 0000000000..edf5735f7d --- /dev/null +++ b/components/system/Desktop/Widgets/StyledWidget.ts @@ -0,0 +1,23 @@ +import styled from "styled-components"; + +const StyledWidget = styled.div` + backdrop-filter: ${({ theme }) => `blur(${theme.sizes.taskbar.panelBlur})`}; + background-color: ${({ theme }) => theme.colors.taskbar.background}; + border: ${({ theme }) => `1px solid ${theme.colors.taskbar.peekBorder}`}; + border-radius: 8px; + box-shadow: ${({ theme }) => theme.colors.window.shadow}; + color: ${({ theme }) => theme.colors.text}; + font-family: ${({ theme }) => theme.formats.systemFont}; + overflow: hidden; + user-select: none; + z-index: 1; + + .widget-header { + align-items: center; + display: flex; + justify-content: space-between; + padding: 10px 14px 6px; + } +`; + +export default StyledWidget; diff --git a/components/system/Desktop/Widgets/WeatherWidget/StyledWeatherWidget.ts b/components/system/Desktop/Widgets/WeatherWidget/StyledWeatherWidget.ts new file mode 100644 index 0000000000..81ec82bb2c --- /dev/null +++ b/components/system/Desktop/Widgets/WeatherWidget/StyledWeatherWidget.ts @@ -0,0 +1,48 @@ +import styled from "styled-components"; +import StyledWidget from "components/system/Desktop/Widgets/StyledWidget"; + +const StyledWeatherWidget = styled(StyledWidget)` + min-width: 220px; + + .weather-body { + display: flex; + gap: 12px; + padding: 2px 14px 14px; + } + + .weather-emoji { + font-size: 40px; + line-height: 1; + } + + .weather-info { + display: flex; + flex-direction: column; + gap: 2px; + } + + .weather-temp { + font-size: 28px; + font-weight: 300; + line-height: 1; + } + + .weather-desc { + font-size: 12px; + opacity: 80%; + } + + .weather-location { + font-size: 11px; + opacity: 60%; + } + + .weather-loading, + .weather-error { + font-size: 12px; + opacity: 60%; + padding: 8px 14px 14px; + } +`; + +export default StyledWeatherWidget; diff --git a/components/system/Desktop/Widgets/WeatherWidget/index.tsx b/components/system/Desktop/Widgets/WeatherWidget/index.tsx new file mode 100644 index 0000000000..76bbb7eca4 --- /dev/null +++ b/components/system/Desktop/Widgets/WeatherWidget/index.tsx @@ -0,0 +1,48 @@ +import { memo } from "react"; +import StyledWeatherWidget from "components/system/Desktop/Widgets/WeatherWidget/StyledWeatherWidget"; +import useWeather, { + getWeatherEmoji, +} from "components/system/Desktop/Widgets/WeatherWidget/useWeather"; +import useDraggableWidget, { + type Position, +} from "components/system/Desktop/Widgets/useDraggableWidget"; + +type WeatherWidgetProps = { + defaultPosition: Position; +}; + +const WeatherWidget: FC = ({ defaultPosition }) => { + const { data, error, loading } = useWeather(); + const { dragHandleProps, style } = useDraggableWidget(defaultPosition); + + return ( + +
+ Weather +
+ {loading && !data && ( +
Loading weather...
+ )} + {error && !data && ( +
Unable to load weather data
+ )} + {data && ( +
+ + {getWeatherEmoji(data.description)} + +
+ {data.tempC}°C + {data.description} + + {data.city} + {data.country ? `, ${data.country}` : ""} + +
+
+ )} +
+ ); +}; + +export default memo(WeatherWidget); diff --git a/components/system/Desktop/Widgets/WeatherWidget/useWeather.ts b/components/system/Desktop/Widgets/WeatherWidget/useWeather.ts new file mode 100644 index 0000000000..ba3baa7155 --- /dev/null +++ b/components/system/Desktop/Widgets/WeatherWidget/useWeather.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +type WeatherCondition = { + temp_C: string; + temp_F: string; + weatherDesc: { value: string }[]; + weatherIconUrl: { value: string }[]; +}; + +type WttrResponse = { + current_condition: WeatherCondition[]; + nearest_area: { + areaName: { value: string }[]; + country: { value: string }[]; + }[]; +}; + +type WeatherData = { + city: string; + country: string; + description: string; + tempC: string; + tempF: string; +}; + +type UseWeatherReturn = { + data: WeatherData | undefined; + error: boolean; + loading: boolean; +}; + +const WTTR_API = "https://wttr.in/?format=j1"; +const REFRESH_INTERVAL_MS = 1_800_000; // 30 minutes + +const WEATHER_EMOJI_MAP: Record = { + Clear: "\u2600\uFE0F", + Cloudy: "\u2601\uFE0F", + Fog: "\uD83C\uDF2B\uFE0F", + "Heavy rain": "\uD83C\uDF27\uFE0F", + "Heavy snow": "\u2744\uFE0F", + "Light drizzle": "\uD83C\uDF26\uFE0F", + "Light rain": "\uD83C\uDF26\uFE0F", + "Light rain shower": "\uD83C\uDF26\uFE0F", + "Light snow": "\uD83C\uDF28\uFE0F", + "Moderate rain": "\uD83C\uDF27\uFE0F", + "Moderate snow": "\uD83C\uDF28\uFE0F", + Overcast: "\u2601\uFE0F", + "Partly Cloudy": "\u26C5", + "Partly cloudy": "\u26C5", + "Patchy rain nearby": "\uD83C\uDF26\uFE0F", + "Patchy rain possible": "\uD83C\uDF26\uFE0F", + Sunny: "\u2600\uFE0F", + Thunderstorm: "\u26C8\uFE0F", +}; + +export const getWeatherEmoji = (description: string): string => + WEATHER_EMOJI_MAP[description] ?? "\uD83C\uDF24\uFE0F"; + +const useWeather = (): UseWeatherReturn => { + const [data, setData] = useState(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const intervalRef = useRef>(undefined); + + const fetchWeather = useCallback(async (): Promise => { + try { + setLoading(true); + setError(false); + + const response = await fetch(WTTR_API); + + if (!response.ok) { + setError(true); + return; + } + + const json = (await response.json()) as WttrResponse; + const condition = json.current_condition?.[0]; + const area = json.nearest_area?.[0]; + + if (!condition || !area) { + setError(true); + return; + } + + setData({ + city: area.areaName[0]?.value ?? "Unknown", + country: area.country[0]?.value ?? "", + description: condition.weatherDesc[0]?.value ?? "Unknown", + tempC: condition.temp_C, + tempF: condition.temp_F, + }); + } catch { + setError(true); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchWeather(); + intervalRef.current = setInterval(fetchWeather, REFRESH_INTERVAL_MS); + + return (): void => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [fetchWeather]); + + return { data, error, loading }; +}; + +export default useWeather; diff --git a/components/system/Desktop/Widgets/YouTubeWidget/StyledYouTubeWidget.ts b/components/system/Desktop/Widgets/YouTubeWidget/StyledYouTubeWidget.ts new file mode 100644 index 0000000000..6ccd922338 --- /dev/null +++ b/components/system/Desktop/Widgets/YouTubeWidget/StyledYouTubeWidget.ts @@ -0,0 +1,19 @@ +import styled from "styled-components"; +import StyledWidget from "components/system/Desktop/Widgets/StyledWidget"; + +const StyledYouTubeWidget = styled(StyledWidget)` + width: 360px; + + .youtube-body { + padding: 0 0 4px; + } + + iframe { + aspect-ratio: 16 / 9; + border: 0; + display: block; + width: 100%; + } +`; + +export default StyledYouTubeWidget; diff --git a/components/system/Desktop/Widgets/YouTubeWidget/index.tsx b/components/system/Desktop/Widgets/YouTubeWidget/index.tsx new file mode 100644 index 0000000000..62600593e1 --- /dev/null +++ b/components/system/Desktop/Widgets/YouTubeWidget/index.tsx @@ -0,0 +1,35 @@ +import { memo } from "react"; +import StyledYouTubeWidget from "components/system/Desktop/Widgets/YouTubeWidget/StyledYouTubeWidget"; +import useDraggableWidget, { + type Position, +} from "components/system/Desktop/Widgets/useDraggableWidget"; + +const YOUTUBE_VIDEO_ID = "dQw4w9WgXcQ"; +const EMBED_URL = `https://www.youtube.com/embed/${YOUTUBE_VIDEO_ID}`; + +type YouTubeWidgetProps = { + defaultPosition: Position; +}; + +const YouTubeWidget: FC = ({ defaultPosition }) => { + const { dragHandleProps, style } = useDraggableWidget(defaultPosition); + + return ( + +
+ YouTube +
+
+