feat: Clock widget and dayjs locale standard (#79)

* feat: Clock widget and dayjs locale standard

Co-authored-by: Meier Lukas
- Widget options modifications
<meierschlumpf@gmail.com>

* perf: add improved time state for clock widget

* fix: final fixes

* refactor: unify selectOptions

* chore: fix CI & remove serverdata from clock widget

* chore: Change custom title to be under a toggle

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Tagaishi
2024-03-09 19:25:48 +01:00
committed by GitHub
parent dceec34929
commit edcba9ceb6
10 changed files with 213 additions and 60 deletions

View File

@@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
import type { WidgetOptionDefinition } from "node_modules/@homarr/widgets/src/options";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { ActionIcon, Affix, IconPencil } from "@homarr/ui";
@@ -28,15 +27,11 @@ export const WidgetPreviewPageContent = ({
integrationData,
}: WidgetPreviewPageContentProps) => {
const currentDefinition = widgetImports[kind].definition;
const options = currentDefinition.options as Record<
string,
WidgetOptionDefinition
>;
const [state, setState] = useState<{
options: Record<string, unknown>;
integrations: string[];
}>({
options: reduceWidgetOptionsWithDefaultValues(kind, options),
options: reduceWidgetOptionsWithDefaultValues(kind, {}),
integrations: [],
});
@@ -67,7 +62,7 @@ export const WidgetPreviewPageContent = ({
integrationData: integrationData.filter(
(integration) =>
"supportedIntegrations" in currentDefinition &&
currentDefinition.supportedIntegrations.some(
(currentDefinition.supportedIntegrations as string[]).some(
(kind) => kind === integration.kind,
),
),

View File

@@ -1,5 +1,9 @@
import "dayjs/locale/de";
import dayjs from "dayjs";
dayjs.locale("de");
export default {
user: {
page: {

View File

@@ -306,15 +306,34 @@ export default {
name: "Date and time",
description: "Displays the current date and time.",
option: {
customTitleToggle: {
label: "Custom Title/City display",
description:
"Show off a custom title or the name of the city/country on top of the clock.",
},
customTitle: {
label: "Title",
},
is24HourFormat: {
label: "24-hour format",
description: "Use 24-hour format instead of 12-hour format",
},
isLocaleTime: {
label: "Use locale time",
showSeconds: {
label: "Display seconds",
},
useCustomTimezone: {
label: "Use a fixed timezone",
},
timezone: {
label: "Timezone",
description: "Choose the timezone following the IANA standard",
},
showDate: {
label: "Show the date",
},
dateFormat: {
label: "Date Format",
description: "How the date should look like",
},
},
},

View File

@@ -5,6 +5,7 @@ import { MultiSelect } from "@homarr/ui";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
import type { SelectOption } from "./widget-select-input";
export const WidgetMultiSelectInput = ({
property,
@@ -17,8 +18,9 @@ export const WidgetMultiSelectInput = ({
return (
<MultiSelect
label={t("label")}
data={options.options as unknown as string[]}
data={options.options as unknown as SelectOption[]}
description={options.withDescription ? t("description") : undefined}
searchable={options.searchable}
{...form.getInputProps(`options.${property}`)}
/>
);

View File

@@ -6,6 +6,20 @@ import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export type SelectOption =
| {
value: string;
label: string;
}
| string;
export type inferSelectOptionValue<TOption extends SelectOption> =
TOption extends {
value: infer TValue;
}
? TValue
: TOption;
export const WidgetSelectInput = ({
property,
kind,
@@ -17,8 +31,9 @@ export const WidgetSelectInput = ({
return (
<Select
label={t("label")}
data={options.options as unknown as string[]}
data={options.options as unknown as SelectOption[]}
description={options.withDescription ? t("description") : undefined}
searchable={options.searchable}
{...form.getInputProps(`options.${property}`)}
/>
);

View File

@@ -1,9 +1,94 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import timezones from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { Flex, Stack, Text } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
dayjs.extend(advancedFormat);
dayjs.extend(utc);
dayjs.extend(timezones);
export default function ClockWidget({
options: _options,
integrations: _integrations,
serverData: _serverData,
options,
}: WidgetComponentProps<"clock">) {
return <div>CLOCK</div>;
const secondsFormat = options.showSeconds ? ":ss" : "";
const timeFormat = options.is24HourFormat
? `HH:mm${secondsFormat}`
: `h:mm${secondsFormat} A`;
const dateFormat = options.dateFormat;
const timezone = options.useCustomTimezone
? options.timezone
: Intl.DateTimeFormat().resolvedOptions().timeZone;
const time = useCurrentTime(options);
return (
<Flex
classNames={{ root: "clock-wrapper" }}
align="center"
justify="center"
h="100%"
>
<Stack classNames={{ root: "clock-text-stack" }} align="center" gap="xs">
{options.customTitleToggle && (
<Text classNames={{ root: "clock-customTitle-text" }}>
{options.customTitle}
</Text>
)}
<Text
classNames={{ root: "clock-time-text" }}
fw={700}
size="2.125rem"
lh="1"
>
{dayjs(time).tz(timezone).format(timeFormat)}
</Text>
{options.showDate && (
<Text classNames={{ root: "clock-date-text" }} lineClamp={1}>
{dayjs(time).tz(timezone).format(dateFormat)}
</Text>
)}
</Stack>
</Flex>
);
}
interface UseCurrentTimeProps {
showSeconds: boolean;
}
const useCurrentTime = ({ showSeconds }: UseCurrentTimeProps) => {
const [time, setTime] = useState(new Date());
const timeoutRef = useRef<NodeJS.Timeout>();
const intervalRef = useRef<NodeJS.Timeout>();
const intervalMultiplier = useMemo(
() => (showSeconds ? 1 : 60),
[showSeconds],
);
useEffect(() => {
setTime(new Date());
timeoutRef.current = setTimeout(
() => {
setTime(new Date());
intervalRef.current = setInterval(() => {
setTime(new Date());
}, intervalMultiplier * 1000);
},
intervalMultiplier * 1000 -
(1000 * (showSeconds ? 0 : dayjs().second()) + dayjs().millisecond()),
);
return () => {
clearTimeout(timeoutRef.current);
clearInterval(intervalRef.current);
};
}, [intervalMultiplier, showSeconds]);
return time;
};

View File

@@ -1,30 +1,63 @@
import dayjs from "dayjs";
import { IconClock } from "@homarr/ui";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } =
createWidgetDefinition("clock", {
icon: IconClock,
supportedIntegrations: ["adGuardHome", "piHole"],
options: optionsBuilder.from(
(factory) => ({
is24HourFormat: factory.switch({
defaultValue: true,
withDescription: true,
}),
isLocaleTime: factory.switch({ defaultValue: true }),
timezone: factory.select({
options: ["Europe/Berlin", "Europe/London", "Europe/Moscow"] as const,
defaultValue: "Europe/Berlin",
}),
export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock,
options: optionsBuilder.from(
(factory) => ({
customTitleToggle: factory.switch({
defaultValue: false,
withDescription: true,
}),
{
timezone: {
shouldHide: (options) => options.isLocaleTime,
},
customTitle: factory.text({
defaultValue: "",
}),
is24HourFormat: factory.switch({
defaultValue: true,
withDescription: true,
}),
showSeconds: factory.switch({
defaultValue: false,
}),
useCustomTimezone: factory.switch({ defaultValue: false }),
timezone: factory.select({
options: Intl.supportedValuesOf("timeZone").map((value) => value),
defaultValue: "Europe/London",
searchable: true,
withDescription: true,
}),
showDate: factory.switch({
defaultValue: true,
}),
dateFormat: factory.select({
options: [
{ value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") },
{ value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") },
{ value: "MMM D", label: dayjs().format("MMM D") },
{ value: "D MMM", label: dayjs().format("D MMM") },
{ value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") },
{ value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") },
{ value: "DD/MM", label: dayjs().format("DD/MM") },
{ value: "MM/DD", label: dayjs().format("MM/DD") },
],
defaultValue: "dddd, MMMM D",
withDescription: true,
}),
}),
{
customTitle: {
shouldHide: (options) => !options.customTitleToggle,
},
),
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
timezone: {
shouldHide: (options) => !options.useCustomTimezone,
},
dateFormat: {
shouldHide: (options) => !options.showDate,
},
},
),
}).withDynamicImport(() => import("./component"));

View File

@@ -1,10 +0,0 @@
"use server";
import { db } from "../../../db";
import type { WidgetProps } from "../definition";
export default async function getServerData(_item: WidgetProps<"clock">) {
const randomUuid = crypto.randomUUID();
const data = await db.query.items.findMany();
return { data, count: data.length, randomUuid };
}

View File

@@ -3,6 +3,10 @@ import type { WidgetKind } from "@homarr/definitions";
import type { z } from "@homarr/validation";
import { widgetImports } from ".";
import type {
inferSelectOptionValue,
SelectOption,
} from "./_inputs/widget-select-input";
interface CommonInput<TType> {
defaultValue?: TType;
@@ -10,17 +14,19 @@ interface CommonInput<TType> {
}
interface TextInput extends CommonInput<string> {
validate: z.ZodType<string>;
validate?: z.ZodType<string>;
}
interface MultiSelectInput<TOptions extends string[]>
extends CommonInput<TOptions[number][]> {
interface MultiSelectInput<TOptions extends SelectOption[]>
extends CommonInput<inferSelectOptionValue<TOptions[number]>[]> {
options: TOptions;
searchable?: boolean;
}
interface SelectInput<TOptions extends readonly [string, ...string[]]>
extends CommonInput<TOptions[number]> {
interface SelectInput<TOptions extends readonly SelectOption[]>
extends CommonInput<inferSelectOptionValue<TOptions[number]>> {
options: TOptions;
searchable?: boolean;
}
interface NumberInput extends CommonInput<number | ""> {
@@ -51,20 +57,23 @@ const optionsFactory = {
withDescription: input?.withDescription ?? false,
validate: input?.validate,
}),
multiSelect: <TOptions extends string[]>(
multiSelect: <const TOptions extends SelectOption[]>(
input: MultiSelectInput<TOptions>,
) => ({
type: "multiSelect" as const,
defaultValue: input.defaultValue ?? [],
options: input.options,
searchable: input.searchable ?? false,
withDescription: input.withDescription ?? false,
}),
select: <TOptions extends readonly [string, ...string[]]>(
select: <const TOptions extends SelectOption[]>(
input: SelectInput<TOptions>,
) => ({
type: "select" as const,
defaultValue: input.defaultValue ?? input.options[0],
defaultValue: (input.defaultValue ??
input.options[0]) as inferSelectOptionValue<TOptions[number]>,
options: input.options,
searchable: input.searchable ?? false,
withDescription: input.withDescription ?? false,
}),
number: (input: NumberInput) => ({

View File

@@ -32,12 +32,13 @@ interface ItemDataLoaderProps {
item: Board["sections"][number]["items"][number];
}
const ItemDataLoader = async ({ item }: ItemDataLoaderProps) => {
const ItemDataLoader = /*async*/ ({ item }: ItemDataLoaderProps) => {
const widgetImport = widgetImports[item.kind];
if (!("serverDataLoader" in widgetImport)) {
return <ClientServerDataInitalizer id={item.id} serverData={undefined} />;
}
const loader = await widgetImport.serverDataLoader();
const data = await loader.default(item as never);
return <ClientServerDataInitalizer id={item.id} serverData={data} />;
//const loader = await widgetImport.serverDataLoader();
//const data = await loader.default(item as never);
//return <ClientServerDataInitalizer id={item.id} serverData={data} />;
return null;
};