Files
homarr/packages/widgets/src/clock/advanced-view.tsx
T
Thomas eb6d337dfb fix: unify widget scroll areas (#6679)
* fix: unify widget scroll areas

* fix: cover all widget scroll areas

* fix: align data table scrollbars
2026-08-23 01:58:06 +02:00

327 lines
10 KiB
TypeScript

import {
Alert,
Badge,
Box,
Divider,
Group,
Paper,
ScrollArea,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { IconAlertTriangle, IconClock } from "@tabler/icons-react";
import type { Dayjs } from "dayjs";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
import type { WidgetComponentProps } from "../definition";
import { clockTimeFormatShowsSeconds, clockTimeFormatUses12Hours, resolveClockTimeFormat } from "./format";
import { TimeOfDayBar } from "./time-of-day-bar";
import { ClockWeatherSummary } from "./weather-summary";
import {
createWorldClockTime,
formatRelativeOffset,
formatUtcOffset,
getTimeOfDayPhase,
humanizeTimeZone,
maximumWorldClockCities,
} from "./world-clock";
import type { TimeOfDayPhase, WorldClockTime } from "./world-clock";
interface AdvancedClockViewProps {
now: Date;
options: WidgetComponentProps<"clock">["options"];
primaryTime: Dayjs;
primaryTimeZone: string;
primaryTimeZoneInvalid: boolean;
}
export const AdvancedClockView = ({
now,
options,
primaryTime,
primaryTimeZone,
primaryTimeZoneInvalid,
}: AdvancedClockViewProps) => {
const t = useI18n("widget.clock");
const primaryLabel = getPrimaryLabel(options, t);
const primary: WorldClockTime = {
id: "primary",
label: primaryLabel,
timeZone: primaryTimeZone,
zonedTime: primaryTime,
phase: getTimeOfDayPhase(primaryTime.hour()),
minuteOfDay: primaryTime.hour() * 60 + primaryTime.minute() + primaryTime.second() / 60,
utcOffset: primaryTime.utcOffset(),
dayDifference: 0,
};
const cities = options.worldClockCities
.slice(0, maximumWorldClockCities)
.filter(({ timeZone }) => timeZone !== primaryTimeZone)
.map((city) => createWorldClockTime({ city, now, primaryTime }));
return (
<ScrollArea
h="100%"
w="100%"
aria-label={t("worldClock.advancedLabel")}
>
<Stack gap="md" p={{ base: "sm", sm: "md" }}>
{primaryTimeZoneInvalid && (
<Alert icon={<IconAlertTriangle />} color="yellow" title={t("worldClock.unsupportedPrimaryTitle")}>
{t("worldClock.unsupportedPrimary", { timeZone: options.timezone })}
</Alert>
)}
<PrimaryClock now={now} options={options} primary={primary} />
<OverviewMode now={now} options={options} primary={primary} cities={cities} />
</Stack>
</ScrollArea>
);
};
interface ModeProps {
now: Date;
options: WidgetComponentProps<"clock">["options"];
primary: WorldClockTime;
cities: WorldClockTime[];
}
const PrimaryClock = ({ now, options, primary }: Pick<ModeProps, "now" | "options" | "primary">) => {
const locale = useCurrentIntlLocale();
const t = useI18n("widget.clock");
const primaryTime = primary.zonedTime;
if (!primaryTime || primary.phase === null || primary.minuteOfDay === null) return null;
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" align="flex-start" gap="lg" wrap="wrap">
<Stack gap={5} miw={0}>
<Group gap="xs" wrap="nowrap">
<ThemeIcon color={getPhaseColor(primary.phase)} variant="light" radius="xl" size="md">
<IconClock size={16} />
</ThemeIcon>
<Title order={2} size="h4" fw={600}>
{primary.label}
</Title>
</Group>
<ClockTime entry={primary} now={now} options={options} primary />
{options.showDate && (
<Text size="sm" c="dimmed">
{formatLocalizedDate(now, locale, { dateStyle: "full", timeZone: primary.timeZone })}
</Text>
)}
<Group gap="xs" wrap="wrap">
<Badge color="gray" variant="light" tt="none">
{primary.timeZone}
</Badge>
<Badge color="gray" variant="outline" tt="none">
{formatUtcOffset(primaryTime.utcOffset())}
</Badge>
</Group>
</Stack>
{options.showWeather && (
<Paper withBorder radius="md" p="sm">
<ClockWeatherSummary
latitude={options.weatherLocation.latitude}
longitude={options.weatherLocation.longitude}
locationName={options.weatherLocation.name}
isFahrenheit={options.isWeatherFormatFahrenheit}
animateIcon={options.animateWeatherIcon}
detailed
/>
</Paper>
)}
</Group>
<Divider my="sm" />
<TimeOfDayBar
minuteOfDay={primary.minuteOfDay}
phase={primary.phase}
showLegend
showTicks
label={t("worldClock.timeBarLabel", { city: primary.label })}
/>
</Paper>
);
};
const OverviewMode = ({ now, options, primary, cities }: ModeProps) => {
const t = useI18n("widget.clock");
if (cities.length === 0) return <EmptyCities />;
return (
<Stack gap="xs">
<Group justify="space-between" align="baseline">
<Title order={3} size="h5">
{t("worldClock.cities")}
</Title>
<Text size="xs" c="dimmed">
{t("worldClock.cityCount", { count: cities.length })}
</Text>
</Group>
<SimpleGrid component="ul" cols={{ base: 1, sm: 2 }} spacing="sm" p={0} m={0} style={{ listStyle: "none" }}>
{cities.map((city) => (
<WorldClockCard
key={city.id}
entry={city}
now={now}
options={options}
primaryOffset={primary.utcOffset ?? 0}
/>
))}
</SimpleGrid>
</Stack>
);
};
interface ClockEntryProps {
entry: WorldClockTime;
now: Date;
options: WidgetComponentProps<"clock">["options"];
primaryOffset: number;
}
const WorldClockCard = ({ entry, now, options, primaryOffset }: ClockEntryProps) => {
if (!entry.zonedTime || entry.utcOffset === null) {
return <InvalidTimeZone entry={entry} />;
}
return (
<Paper component="li" withBorder radius="md" p="md">
<Stack gap="sm">
<Stack gap={0} miw={0}>
<Text fw={600} truncate>
{entry.label}
</Text>
<Text size="xs" c="dimmed" truncate>
{entry.timeZone}
</Text>
</Stack>
<ClockTime entry={entry} now={now} options={options} compact />
<DayAndOffset entry={entry} now={now} primaryOffset={primaryOffset} />
</Stack>
</Paper>
);
};
const ClockTime = ({
entry,
now,
options,
primary = false,
compact = false,
}: Omit<ClockEntryProps, "primaryOffset"> & { primary?: boolean; compact?: boolean }) => {
const locale = useCurrentIntlLocale();
const t = useI18n("widget.clock");
if (!entry.zonedTime) return null;
let formattedTime = formatLocalizedTime(now, locale, {
hour12: clockTimeFormatUses12Hours(options.customTimeFormat, options.is24HourFormat),
includeSeconds: clockTimeFormatShowsSeconds(options.customTimeFormat),
timeZone: entry.timeZone,
});
if (primary) {
formattedTime = entry.zonedTime.format(resolveClockTimeFormat(options.customTimeFormat, options.is24HourFormat));
}
const accessibleDate = formatLocalizedDate(now, locale, {
dateStyle: "medium",
timeZone: entry.timeZone,
});
const fontSize = compact ? { base: 30, sm: 34 } : { base: 44, sm: 56 };
return (
<Text
component="time"
dateTime={now.toISOString()}
fz={fontSize}
fw={700}
ff="var(--mantine-font-family-monospace)"
lh={1}
lts="-0.04em"
aria-label={t("worldClock.accessibleTime", {
city: entry.label,
time: formattedTime,
date: accessibleDate,
timeZone: entry.timeZone,
})}
>
{formattedTime}
</Text>
);
};
const DayAndOffset = ({ entry, now, primaryOffset }: Pick<ClockEntryProps, "entry" | "now" | "primaryOffset">) => {
const locale = useCurrentIntlLocale();
const t = useI18n("widget.clock");
if (entry.dayDifference === null || entry.utcOffset === null) return null;
const dayRelation = getDayRelation(entry.dayDifference, t);
const date = formatLocalizedDate(now, locale, {
weekday: "short",
month: "short",
day: "numeric",
timeZone: entry.timeZone,
});
return (
<Group gap="xs" wrap="wrap">
<Badge color="gray" variant="light" size="sm" tt="none">
{date} · {dayRelation}
</Badge>
<Text size="xs" c="dimmed">
{formatRelativeOffset(entry.utcOffset - primaryOffset)} · {formatUtcOffset(entry.utcOffset)}
</Text>
</Group>
);
};
const InvalidTimeZone = ({ entry }: { entry: WorldClockTime }) => {
const t = useI18n("widget.clock");
return (
<Paper component="li" withBorder radius="md" p="md">
<Group gap="xs" wrap="nowrap">
<IconAlertTriangle color="var(--mantine-color-yellow-6)" aria-hidden />
<Box>
<Text fw={600}>{entry.label}</Text>
<Text size="xs" c="dimmed">
{t("worldClock.unsupportedTimeZone", { timeZone: entry.timeZone })}
</Text>
</Box>
</Group>
</Paper>
);
};
const EmptyCities = () => {
const t = useI18n("widget.clock");
return (
<Paper withBorder radius="md" p="xl">
<Text c="dimmed" ta="center">
{t("worldClock.noSecondaryCities")}
</Text>
</Paper>
);
};
type ScopedClockTranslator = ReturnType<typeof useI18n<"widget.clock">>;
const getDayRelation = (difference: number, t: ScopedClockTranslator) => {
if (difference < 0) return t("worldClock.yesterday");
if (difference > 0) return t("worldClock.tomorrow");
return t("worldClock.today");
};
const getPrimaryLabel = (options: ModeProps["options"], t: ScopedClockTranslator) => {
if (options.customTitleToggle) return options.customTitle || t("worldClock.primary");
if (options.useCustomTimezone) return humanizeTimeZone(options.timezone);
return t("worldClock.local");
};
const getPhaseColor = (phase: TimeOfDayPhase) => {
if (phase === "night") return "indigo";
if (phase === "dawn") return "orange";
if (phase === "day") return "cyan";
return "grape";
};