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:
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { WidgetOptionDefinition } from "node_modules/@homarr/widgets/src/options";
|
|
||||||
|
|
||||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||||
import { ActionIcon, Affix, IconPencil } from "@homarr/ui";
|
import { ActionIcon, Affix, IconPencil } from "@homarr/ui";
|
||||||
@@ -28,15 +27,11 @@ export const WidgetPreviewPageContent = ({
|
|||||||
integrationData,
|
integrationData,
|
||||||
}: WidgetPreviewPageContentProps) => {
|
}: WidgetPreviewPageContentProps) => {
|
||||||
const currentDefinition = widgetImports[kind].definition;
|
const currentDefinition = widgetImports[kind].definition;
|
||||||
const options = currentDefinition.options as Record<
|
|
||||||
string,
|
|
||||||
WidgetOptionDefinition
|
|
||||||
>;
|
|
||||||
const [state, setState] = useState<{
|
const [state, setState] = useState<{
|
||||||
options: Record<string, unknown>;
|
options: Record<string, unknown>;
|
||||||
integrations: string[];
|
integrations: string[];
|
||||||
}>({
|
}>({
|
||||||
options: reduceWidgetOptionsWithDefaultValues(kind, options),
|
options: reduceWidgetOptionsWithDefaultValues(kind, {}),
|
||||||
integrations: [],
|
integrations: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,7 +62,7 @@ export const WidgetPreviewPageContent = ({
|
|||||||
integrationData: integrationData.filter(
|
integrationData: integrationData.filter(
|
||||||
(integration) =>
|
(integration) =>
|
||||||
"supportedIntegrations" in currentDefinition &&
|
"supportedIntegrations" in currentDefinition &&
|
||||||
currentDefinition.supportedIntegrations.some(
|
(currentDefinition.supportedIntegrations as string[]).some(
|
||||||
(kind) => kind === integration.kind,
|
(kind) => kind === integration.kind,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import "dayjs/locale/de";
|
import "dayjs/locale/de";
|
||||||
|
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
dayjs.locale("de");
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
user: {
|
user: {
|
||||||
page: {
|
page: {
|
||||||
|
|||||||
@@ -306,15 +306,34 @@ export default {
|
|||||||
name: "Date and time",
|
name: "Date and time",
|
||||||
description: "Displays the current date and time.",
|
description: "Displays the current date and time.",
|
||||||
option: {
|
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: {
|
is24HourFormat: {
|
||||||
label: "24-hour format",
|
label: "24-hour format",
|
||||||
description: "Use 24-hour format instead of 12-hour format",
|
description: "Use 24-hour format instead of 12-hour format",
|
||||||
},
|
},
|
||||||
isLocaleTime: {
|
showSeconds: {
|
||||||
label: "Use locale time",
|
label: "Display seconds",
|
||||||
|
},
|
||||||
|
useCustomTimezone: {
|
||||||
|
label: "Use a fixed timezone",
|
||||||
},
|
},
|
||||||
timezone: {
|
timezone: {
|
||||||
label: "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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MultiSelect } from "@homarr/ui";
|
|||||||
import type { CommonWidgetInputProps } from "./common";
|
import type { CommonWidgetInputProps } from "./common";
|
||||||
import { useWidgetInputTranslation } from "./common";
|
import { useWidgetInputTranslation } from "./common";
|
||||||
import { useFormContext } from "./form";
|
import { useFormContext } from "./form";
|
||||||
|
import type { SelectOption } from "./widget-select-input";
|
||||||
|
|
||||||
export const WidgetMultiSelectInput = ({
|
export const WidgetMultiSelectInput = ({
|
||||||
property,
|
property,
|
||||||
@@ -17,8 +18,9 @@ export const WidgetMultiSelectInput = ({
|
|||||||
return (
|
return (
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label={t("label")}
|
label={t("label")}
|
||||||
data={options.options as unknown as string[]}
|
data={options.options as unknown as SelectOption[]}
|
||||||
description={options.withDescription ? t("description") : undefined}
|
description={options.withDescription ? t("description") : undefined}
|
||||||
|
searchable={options.searchable}
|
||||||
{...form.getInputProps(`options.${property}`)}
|
{...form.getInputProps(`options.${property}`)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,20 @@ import type { CommonWidgetInputProps } from "./common";
|
|||||||
import { useWidgetInputTranslation } from "./common";
|
import { useWidgetInputTranslation } from "./common";
|
||||||
import { useFormContext } from "./form";
|
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 = ({
|
export const WidgetSelectInput = ({
|
||||||
property,
|
property,
|
||||||
kind,
|
kind,
|
||||||
@@ -17,8 +31,9 @@ export const WidgetSelectInput = ({
|
|||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
label={t("label")}
|
label={t("label")}
|
||||||
data={options.options as unknown as string[]}
|
data={options.options as unknown as SelectOption[]}
|
||||||
description={options.withDescription ? t("description") : undefined}
|
description={options.withDescription ? t("description") : undefined}
|
||||||
|
searchable={options.searchable}
|
||||||
{...form.getInputProps(`options.${property}`)}
|
{...form.getInputProps(`options.${property}`)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
|
||||||
|
dayjs.extend(advancedFormat);
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezones);
|
||||||
|
|
||||||
export default function ClockWidget({
|
export default function ClockWidget({
|
||||||
options: _options,
|
options,
|
||||||
integrations: _integrations,
|
|
||||||
serverData: _serverData,
|
|
||||||
}: WidgetComponentProps<"clock">) {
|
}: 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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,30 +1,63 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import { IconClock } from "@homarr/ui";
|
import { IconClock } from "@homarr/ui";
|
||||||
|
|
||||||
import { createWidgetDefinition } from "../definition";
|
import { createWidgetDefinition } from "../definition";
|
||||||
import { optionsBuilder } from "../options";
|
import { optionsBuilder } from "../options";
|
||||||
|
|
||||||
export const { definition, componentLoader, serverDataLoader } =
|
export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||||
createWidgetDefinition("clock", {
|
icon: IconClock,
|
||||||
icon: IconClock,
|
options: optionsBuilder.from(
|
||||||
supportedIntegrations: ["adGuardHome", "piHole"],
|
(factory) => ({
|
||||||
options: optionsBuilder.from(
|
customTitleToggle: factory.switch({
|
||||||
(factory) => ({
|
defaultValue: false,
|
||||||
is24HourFormat: factory.switch({
|
withDescription: true,
|
||||||
defaultValue: true,
|
|
||||||
withDescription: true,
|
|
||||||
}),
|
|
||||||
isLocaleTime: factory.switch({ defaultValue: true }),
|
|
||||||
timezone: factory.select({
|
|
||||||
options: ["Europe/Berlin", "Europe/London", "Europe/Moscow"] as const,
|
|
||||||
defaultValue: "Europe/Berlin",
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
{
|
customTitle: factory.text({
|
||||||
timezone: {
|
defaultValue: "",
|
||||||
shouldHide: (options) => options.isLocaleTime,
|
}),
|
||||||
},
|
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,
|
||||||
},
|
},
|
||||||
),
|
timezone: {
|
||||||
})
|
shouldHide: (options) => !options.useCustomTimezone,
|
||||||
.withServerData(() => import("./serverData"))
|
},
|
||||||
.withDynamicImport(() => import("./component"));
|
dateFormat: {
|
||||||
|
shouldHide: (options) => !options.showDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}).withDynamicImport(() => import("./component"));
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,10 @@ import type { WidgetKind } from "@homarr/definitions";
|
|||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { widgetImports } from ".";
|
import { widgetImports } from ".";
|
||||||
|
import type {
|
||||||
|
inferSelectOptionValue,
|
||||||
|
SelectOption,
|
||||||
|
} from "./_inputs/widget-select-input";
|
||||||
|
|
||||||
interface CommonInput<TType> {
|
interface CommonInput<TType> {
|
||||||
defaultValue?: TType;
|
defaultValue?: TType;
|
||||||
@@ -10,17 +14,19 @@ interface CommonInput<TType> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TextInput extends CommonInput<string> {
|
interface TextInput extends CommonInput<string> {
|
||||||
validate: z.ZodType<string>;
|
validate?: z.ZodType<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MultiSelectInput<TOptions extends string[]>
|
interface MultiSelectInput<TOptions extends SelectOption[]>
|
||||||
extends CommonInput<TOptions[number][]> {
|
extends CommonInput<inferSelectOptionValue<TOptions[number]>[]> {
|
||||||
options: TOptions;
|
options: TOptions;
|
||||||
|
searchable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectInput<TOptions extends readonly [string, ...string[]]>
|
interface SelectInput<TOptions extends readonly SelectOption[]>
|
||||||
extends CommonInput<TOptions[number]> {
|
extends CommonInput<inferSelectOptionValue<TOptions[number]>> {
|
||||||
options: TOptions;
|
options: TOptions;
|
||||||
|
searchable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NumberInput extends CommonInput<number | ""> {
|
interface NumberInput extends CommonInput<number | ""> {
|
||||||
@@ -51,20 +57,23 @@ const optionsFactory = {
|
|||||||
withDescription: input?.withDescription ?? false,
|
withDescription: input?.withDescription ?? false,
|
||||||
validate: input?.validate,
|
validate: input?.validate,
|
||||||
}),
|
}),
|
||||||
multiSelect: <TOptions extends string[]>(
|
multiSelect: <const TOptions extends SelectOption[]>(
|
||||||
input: MultiSelectInput<TOptions>,
|
input: MultiSelectInput<TOptions>,
|
||||||
) => ({
|
) => ({
|
||||||
type: "multiSelect" as const,
|
type: "multiSelect" as const,
|
||||||
defaultValue: input.defaultValue ?? [],
|
defaultValue: input.defaultValue ?? [],
|
||||||
options: input.options,
|
options: input.options,
|
||||||
|
searchable: input.searchable ?? false,
|
||||||
withDescription: input.withDescription ?? false,
|
withDescription: input.withDescription ?? false,
|
||||||
}),
|
}),
|
||||||
select: <TOptions extends readonly [string, ...string[]]>(
|
select: <const TOptions extends SelectOption[]>(
|
||||||
input: SelectInput<TOptions>,
|
input: SelectInput<TOptions>,
|
||||||
) => ({
|
) => ({
|
||||||
type: "select" as const,
|
type: "select" as const,
|
||||||
defaultValue: input.defaultValue ?? input.options[0],
|
defaultValue: (input.defaultValue ??
|
||||||
|
input.options[0]) as inferSelectOptionValue<TOptions[number]>,
|
||||||
options: input.options,
|
options: input.options,
|
||||||
|
searchable: input.searchable ?? false,
|
||||||
withDescription: input.withDescription ?? false,
|
withDescription: input.withDescription ?? false,
|
||||||
}),
|
}),
|
||||||
number: (input: NumberInput) => ({
|
number: (input: NumberInput) => ({
|
||||||
|
|||||||
@@ -32,12 +32,13 @@ interface ItemDataLoaderProps {
|
|||||||
item: Board["sections"][number]["items"][number];
|
item: Board["sections"][number]["items"][number];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ItemDataLoader = async ({ item }: ItemDataLoaderProps) => {
|
const ItemDataLoader = /*async*/ ({ item }: ItemDataLoaderProps) => {
|
||||||
const widgetImport = widgetImports[item.kind];
|
const widgetImport = widgetImports[item.kind];
|
||||||
if (!("serverDataLoader" in widgetImport)) {
|
if (!("serverDataLoader" in widgetImport)) {
|
||||||
return <ClientServerDataInitalizer id={item.id} serverData={undefined} />;
|
return <ClientServerDataInitalizer id={item.id} serverData={undefined} />;
|
||||||
}
|
}
|
||||||
const loader = await widgetImport.serverDataLoader();
|
//const loader = await widgetImport.serverDataLoader();
|
||||||
const data = await loader.default(item as never);
|
//const data = await loader.default(item as never);
|
||||||
return <ClientServerDataInitalizer id={item.id} serverData={data} />;
|
//return <ClientServerDataInitalizer id={item.id} serverData={data} />;
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user