mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 08:20:56 +01:00
feat: add widget preview pages (#9)
* feat: add widget definition system * fix: wrong typecheck command in turbo generator * chore: fix formatting * feat: add widget preview page * chore: fix formatting and type errors * chore: fix from widget edit modal and remove some never casts * chore: address pull request feedback
This commit is contained in:
@@ -16,14 +16,15 @@
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/spotlight": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/spotlight": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@mantine/hooks": "^7.3.1",
|
||||
"@mantine/tiptap": "^7.3.1",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@mantine/hooks": "^7.3.2",
|
||||
"@mantine/tiptap": "^7.3.2",
|
||||
"@t3-oss/env-nextjs": "^0.7.1",
|
||||
"@tanstack/react-query": "^5.8.7",
|
||||
"@tanstack/react-query-devtools": "^5.8.7",
|
||||
@@ -36,6 +37,8 @@
|
||||
"@trpc/react-query": "next",
|
||||
"@trpc/server": "next",
|
||||
"dayjs": "^1.11.10",
|
||||
"jotai": "^2.6.0",
|
||||
"mantine-modal-manager": "^7.3.2",
|
||||
"next": "^14.0.3",
|
||||
"postcss-preset-mantine": "^1.11.1",
|
||||
"react": "18.2.0",
|
||||
|
||||
15
apps/nextjs/src/app/[locale]/(main)/layout.tsx
Normal file
15
apps/nextjs/src/app/[locale]/(main)/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import { AppShellMain } from "@homarr/ui";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
|
||||
export default function MainLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader />
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
);
|
||||
}
|
||||
9
apps/nextjs/src/app/[locale]/(main)/page.tsx
Normal file
9
apps/nextjs/src/app/[locale]/(main)/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Stack, Title } from "@homarr/ui";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<Stack>
|
||||
<Title>Home</Title>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import { ModalsManager } from "../modals";
|
||||
|
||||
export const ModalsProvider = ({ children }: PropsWithChildren) => {
|
||||
return <ModalsManager>{children}</ModalsManager>;
|
||||
};
|
||||
@@ -10,7 +10,7 @@ export default async function Login() {
|
||||
return (
|
||||
<Center>
|
||||
<Stack align="center" mt="xl">
|
||||
<LogoWithTitle />
|
||||
<LogoWithTitle size="lg" />
|
||||
<Stack gap={6} align="center">
|
||||
<Title order={3} fw={400} ta="center">
|
||||
{t("title")}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default async function InitUser() {
|
||||
return (
|
||||
<Center>
|
||||
<Stack align="center" mt="xl">
|
||||
<LogoWithTitle />
|
||||
<LogoWithTitle size="lg" />
|
||||
<Stack gap={6} align="center">
|
||||
<Title order={3} fw={400} ta="center">
|
||||
{t("title")}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
|
||||
|
||||
import "@homarr/ui/styles.css";
|
||||
import "@homarr/notifications/styles.css";
|
||||
import "@homarr/spotlight/styles.css";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
uiConfiguration,
|
||||
} from "@homarr/ui";
|
||||
|
||||
import { ModalsProvider } from "./_client-providers/modals";
|
||||
import { NextInternationalProvider } from "./_client-providers/next-international";
|
||||
import { TRPCReactProvider } from "./_client-providers/trpc";
|
||||
|
||||
@@ -51,8 +53,10 @@ export default function Layout(props: {
|
||||
defaultColorScheme={colorScheme}
|
||||
{...uiConfiguration}
|
||||
>
|
||||
<Notifications />
|
||||
{props.children}
|
||||
<ModalsProvider>
|
||||
<Notifications />
|
||||
{props.children}
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</NextInternationalProvider>
|
||||
</TRPCReactProvider>
|
||||
|
||||
9
apps/nextjs/src/app/[locale]/modals.tsx
Normal file
9
apps/nextjs/src/app/[locale]/modals.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { createModalManager } from "mantine-modal-manager";
|
||||
|
||||
import { WidgetEditModal } from "@homarr/widgets";
|
||||
|
||||
export const [ModalsManager, modalEvents] = createModalManager({
|
||||
widgetEditModal: WidgetEditModal,
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { auth } from "@homarr/auth";
|
||||
import { db } from "@homarr/db";
|
||||
import { Button, Stack, Title } from "@homarr/ui";
|
||||
|
||||
export default async function HomePage() {
|
||||
const currentSession = await auth();
|
||||
const users = await db.query.users.findMany();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title>Home</Title>
|
||||
<Button>Test</Button>
|
||||
<pre>{JSON.stringify(users)}</pre>
|
||||
{currentSession && (
|
||||
<span>
|
||||
Currently logged in as <b>{currentSession.user.name}</b>
|
||||
</span>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
27
apps/nextjs/src/app/[locale]/widgets/[sort]/layout.tsx
Normal file
27
apps/nextjs/src/app/[locale]/widgets/[sort]/layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
|
||||
const getLinks = () => {
|
||||
return Object.entries(widgetImports).map(([key, value]) => {
|
||||
return {
|
||||
href: `/widgets/${key}`,
|
||||
icon: value.definition.icon,
|
||||
label: value.definition.sort,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default function WidgetPreviewLayout({ children }: PropsWithChildren) {
|
||||
const links = getLinks();
|
||||
|
||||
return (
|
||||
<ClientShell hasHeader={false}>
|
||||
<MainNavigation links={links} />
|
||||
{children}
|
||||
</ClientShell>
|
||||
);
|
||||
}
|
||||
48
apps/nextjs/src/app/[locale]/widgets/[sort]/page.tsx
Normal file
48
apps/nextjs/src/app/[locale]/widgets/[sort]/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useState } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { ActionIcon, Affix, Center, IconPencil } from "@homarr/ui";
|
||||
import type { WidgetSort } from "@homarr/widgets";
|
||||
import { loadWidgetDynamic, widgetImports } from "@homarr/widgets";
|
||||
|
||||
import { modalEvents } from "../../modals";
|
||||
|
||||
type Props = PropsWithChildren<{ params: { sort: string } }>;
|
||||
|
||||
export default function WidgetPreview(props: Props) {
|
||||
const [options, setOptions] = useState<Record<string, unknown>>({});
|
||||
if (!(props.params.sort in widgetImports)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const sort = props.params.sort as WidgetSort;
|
||||
const Comp = loadWidgetDynamic(sort);
|
||||
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Comp options={options as never} integrations={[]} />
|
||||
<Affix bottom={12} right={72}>
|
||||
<ActionIcon
|
||||
size={48}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
onClick={() => {
|
||||
return modalEvents.openManagedModal({
|
||||
modal: "widgetEditModal",
|
||||
innerProps: {
|
||||
sort,
|
||||
definition: widgetImports[sort].definition.options,
|
||||
state: [options, setOptions],
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconPencil size={24} />
|
||||
</ActionIcon>
|
||||
</Affix>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
30
apps/nextjs/src/components/layout/header.tsx
Normal file
30
apps/nextjs/src/components/layout/header.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { AppShellHeader, Group, UnstyledButton } from "@homarr/ui";
|
||||
|
||||
import { ClientBurger } from "./header/burger";
|
||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||
import { ClientSpotlight } from "./header/spotlight";
|
||||
import { UserButton } from "./header/user";
|
||||
import { LogoWithTitle } from "./logo";
|
||||
|
||||
export const MainHeader = () => {
|
||||
return (
|
||||
<AppShellHeader>
|
||||
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
|
||||
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
|
||||
<ClientBurger />
|
||||
<UnstyledButton component={Link} href="/">
|
||||
<LogoWithTitle size="md" />
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
<DesktopSearchInput />
|
||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }}>
|
||||
<MobileSearchButton />
|
||||
<UserButton />
|
||||
</Group>
|
||||
</Group>
|
||||
<ClientSpotlight />
|
||||
</AppShellHeader>
|
||||
);
|
||||
};
|
||||
18
apps/nextjs/src/components/layout/header/burger.tsx
Normal file
18
apps/nextjs/src/components/layout/header/burger.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { atom, useAtom } from "jotai";
|
||||
|
||||
import { Burger } from "@homarr/ui";
|
||||
|
||||
export const navigationCollapsedAtom = atom(true);
|
||||
|
||||
export const ClientBurger = () => {
|
||||
const [collapsed, setCollapsed] = useAtom(navigationCollapsedAtom);
|
||||
|
||||
const toggle = useCallback(() => setCollapsed((c) => !c), [setCollapsed]);
|
||||
|
||||
return (
|
||||
<Burger opened={!collapsed} onClick={toggle} hiddenFrom="sm" size="sm" />
|
||||
);
|
||||
};
|
||||
26
apps/nextjs/src/components/layout/header/search.module.css
Normal file
26
apps/nextjs/src/components/layout/header/search.module.css
Normal file
@@ -0,0 +1,26 @@
|
||||
.desktopSearch {
|
||||
@mixin smaller-than $mantine-breakpoint-sm {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> div {
|
||||
--_input-bd-override: var(--_input-bd);
|
||||
|
||||
button:focus-within {
|
||||
border-color: var(--_input-bd-override);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
color: var(--mantine-color-placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobileSearch {
|
||||
@mixin larger-than $mantine-breakpoint-sm {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
37
apps/nextjs/src/components/layout/header/search.tsx
Normal file
37
apps/nextjs/src/components/layout/header/search.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { spotlight } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { ActionIcon, IconSearch, TextInput, UnstyledButton } from "@homarr/ui";
|
||||
|
||||
import classes from "./search.module.css";
|
||||
|
||||
export const DesktopSearchInput = () => {
|
||||
const t = useScopedI18n("common.search");
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
component={UnstyledButton}
|
||||
className={classes.desktopSearch}
|
||||
w={400}
|
||||
size="sm"
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
onClick={spotlight.open}
|
||||
>
|
||||
{t("placeholder")}
|
||||
</TextInput>
|
||||
);
|
||||
};
|
||||
|
||||
export const MobileSearchButton = () => {
|
||||
return (
|
||||
<ActionIcon
|
||||
className={classes.mobileSearch}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={spotlight.open}
|
||||
>
|
||||
<IconSearch size={20} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
22
apps/nextjs/src/components/layout/header/spotlight.tsx
Normal file
22
apps/nextjs/src/components/layout/header/spotlight.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { Spotlight } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { IconSearch } from "@homarr/ui";
|
||||
|
||||
export const ClientSpotlight = () => {
|
||||
const t = useScopedI18n("common.search");
|
||||
|
||||
return (
|
||||
<Spotlight
|
||||
actions={[]}
|
||||
nothingFound={t("nothingFound")}
|
||||
highlightQuery
|
||||
searchProps={{
|
||||
leftSection: <IconSearch size={20} stroke={1.5} />,
|
||||
placeholder: `${t("placeholder")}`,
|
||||
}}
|
||||
yOffset={12}
|
||||
/>
|
||||
);
|
||||
};
|
||||
11
apps/nextjs/src/components/layout/header/user.tsx
Normal file
11
apps/nextjs/src/components/layout/header/user.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { UnstyledButton } from "@homarr/ui";
|
||||
|
||||
import { UserAvatar } from "~/components/user-avatar";
|
||||
|
||||
export const UserButton = () => {
|
||||
return (
|
||||
<UnstyledButton>
|
||||
<UserAvatar size="md" />
|
||||
</UnstyledButton>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import Image from "next/image";
|
||||
|
||||
import type { TitleOrder } from "@homarr/ui";
|
||||
import { Group, Title } from "@homarr/ui";
|
||||
|
||||
interface LogoProps {
|
||||
@@ -7,12 +8,26 @@ interface LogoProps {
|
||||
}
|
||||
|
||||
export const Logo = ({ size = 60 }: LogoProps) => (
|
||||
<Image src="/logo/homarr.png" alt="homarr logo" width={size} height={size} />
|
||||
<Image src="/logo/homarr.png" alt="Homarr logo" width={size} height={size} />
|
||||
);
|
||||
|
||||
export const LogoWithTitle = () => (
|
||||
<Group gap={0}>
|
||||
<Logo size={48} />
|
||||
<Title order={1}>lparr</Title>
|
||||
</Group>
|
||||
);
|
||||
const logoWithTitleSizes = {
|
||||
lg: { logoSize: 48, titleOrder: 1 },
|
||||
md: { logoSize: 32, titleOrder: 2 },
|
||||
sm: { logoSize: 24, titleOrder: 3 },
|
||||
} satisfies Record<string, { logoSize: number; titleOrder: TitleOrder }>;
|
||||
|
||||
interface LogoWithTitleProps {
|
||||
size: keyof typeof logoWithTitleSizes;
|
||||
}
|
||||
|
||||
export const LogoWithTitle = ({ size }: LogoWithTitleProps) => {
|
||||
const { logoSize, titleOrder } = logoWithTitleSizes[size];
|
||||
|
||||
return (
|
||||
<Group gap={0} wrap="nowrap">
|
||||
<Logo size={logoSize} />
|
||||
<Title order={titleOrder}>lparr</Title>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
88
apps/nextjs/src/components/layout/navigation.tsx
Normal file
88
apps/nextjs/src/components/layout/navigation.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
AppShellNavbar,
|
||||
AppShellSection,
|
||||
NavLink,
|
||||
ScrollArea,
|
||||
} from "@homarr/ui";
|
||||
import type { TablerIconsProps } from "@homarr/ui";
|
||||
|
||||
interface MainNavigationProps {
|
||||
headerSection?: JSX.Element;
|
||||
footerSection?: JSX.Element;
|
||||
links: NavigationLink[];
|
||||
}
|
||||
|
||||
export const MainNavigation = ({
|
||||
headerSection,
|
||||
footerSection,
|
||||
links,
|
||||
}: MainNavigationProps) => {
|
||||
return (
|
||||
<AppShellNavbar p="md">
|
||||
{headerSection && <AppShellSection>{headerSection}</AppShellSection>}
|
||||
<AppShellSection
|
||||
grow
|
||||
mt={headerSection ? "md" : undefined}
|
||||
mb={footerSection ? "md" : undefined}
|
||||
component={ScrollArea}
|
||||
>
|
||||
{links.map((link) => (
|
||||
<CommonNavLink key={link.label} {...link} />
|
||||
))}
|
||||
</AppShellSection>
|
||||
{footerSection && <AppShellSection>{footerSection}</AppShellSection>}
|
||||
</AppShellNavbar>
|
||||
);
|
||||
};
|
||||
|
||||
const CommonNavLink = (props: NavigationLink) =>
|
||||
"href" in props ? (
|
||||
<NavLinkHref {...props} />
|
||||
) : (
|
||||
<NavLinkWithItems {...props} />
|
||||
);
|
||||
|
||||
const NavLinkHref = (props: NavigationLinkHref) =>
|
||||
props.external ? (
|
||||
<NavLink
|
||||
component="a"
|
||||
label={props.label}
|
||||
leftSection={<props.icon size={20} stroke={1.5} />}
|
||||
href={props.href}
|
||||
target="_blank"
|
||||
/>
|
||||
) : (
|
||||
<NavLink
|
||||
component={Link}
|
||||
label={props.label}
|
||||
leftSection={<props.icon size={20} stroke={1.5} />}
|
||||
href={props.href}
|
||||
/>
|
||||
);
|
||||
|
||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => (
|
||||
<NavLink
|
||||
label={props.label}
|
||||
leftSection={<props.icon size={20} stroke={1.5} />}
|
||||
>
|
||||
{props.items.map((item) => (
|
||||
<NavLinkHref key={item.label} {...item} />
|
||||
))}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
interface CommonNavigationLinkProps {
|
||||
label: string;
|
||||
icon: (props: TablerIconsProps) => JSX.Element;
|
||||
}
|
||||
|
||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||
href: string;
|
||||
external?: boolean;
|
||||
}
|
||||
interface NavigationLinkWithItems extends CommonNavigationLinkProps {
|
||||
items: NavigationLinkHref[];
|
||||
}
|
||||
export type NavigationLink = NavigationLinkHref | NavigationLinkWithItems;
|
||||
39
apps/nextjs/src/components/layout/shell.tsx
Normal file
39
apps/nextjs/src/components/layout/shell.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
import { AppShell } from "@homarr/ui";
|
||||
|
||||
import { navigationCollapsedAtom } from "./header/burger";
|
||||
|
||||
interface ClientShellProps {
|
||||
hasHeader?: boolean;
|
||||
hasNavigation?: boolean;
|
||||
}
|
||||
|
||||
export const ClientShell = ({
|
||||
hasHeader = true,
|
||||
hasNavigation = true,
|
||||
children,
|
||||
}: PropsWithChildren<ClientShellProps>) => {
|
||||
const collapsed = useAtomValue(navigationCollapsedAtom);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={hasHeader ? { height: 60 } : undefined}
|
||||
navbar={
|
||||
hasNavigation
|
||||
? {
|
||||
width: 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: collapsed },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
padding="md"
|
||||
>
|
||||
{children}
|
||||
</AppShell>
|
||||
);
|
||||
};
|
||||
32
apps/nextjs/src/components/user-avatar.tsx
Normal file
32
apps/nextjs/src/components/user-avatar.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { auth } from "@homarr/auth";
|
||||
import type { AvatarProps, MantineSize } from "@homarr/ui";
|
||||
import { Avatar } from "@homarr/ui";
|
||||
|
||||
interface UserAvatarProps {
|
||||
size: MantineSize;
|
||||
}
|
||||
|
||||
export const UserAvatar = async ({ size }: UserAvatarProps) => {
|
||||
const currentSession = await auth();
|
||||
|
||||
const commonProps = {
|
||||
size,
|
||||
color: "primaryColor",
|
||||
} satisfies Partial<AvatarProps>;
|
||||
|
||||
if (!currentSession) return <Avatar {...commonProps} />;
|
||||
if (currentSession.user.image)
|
||||
return (
|
||||
<Avatar
|
||||
{...commonProps}
|
||||
src={currentSession.user.image}
|
||||
alt={currentSession.user.name!}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Avatar {...commonProps}>
|
||||
{currentSession.user.name!.substring(0, 2).toUpperCase()}
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./src";
|
||||
export { spotlight, Spotlight } from "@mantine/spotlight";
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
".": "./index.ts",
|
||||
"./styles.css": "./src/styles.css"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
1
packages/spotlight/src/styles.css
Normal file
1
packages/spotlight/src/styles.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "@mantine/spotlight/styles.css";
|
||||
@@ -26,4 +26,36 @@ export default {
|
||||
create: "Benutzer erstellen",
|
||||
},
|
||||
},
|
||||
widget: {
|
||||
clock: {
|
||||
option: {
|
||||
is24HourFormat: {
|
||||
label: "24-Stunden Format",
|
||||
description: "Use 24-hour format instead of 12-hour format",
|
||||
},
|
||||
isLocaleTime: {
|
||||
label: "Use locale time",
|
||||
},
|
||||
timezone: {
|
||||
label: "Timezone",
|
||||
},
|
||||
},
|
||||
},
|
||||
weather: {
|
||||
option: {
|
||||
location: {
|
||||
label: "Standort",
|
||||
},
|
||||
showCity: {
|
||||
label: "Stadt anzeigen",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
common: {
|
||||
search: {
|
||||
placeholder: "Suche nach etwas...",
|
||||
nothingFound: "Nichts gefunden",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -26,4 +26,36 @@ export default {
|
||||
create: "Create user",
|
||||
},
|
||||
},
|
||||
widget: {
|
||||
clock: {
|
||||
option: {
|
||||
is24HourFormat: {
|
||||
label: "24-hour format",
|
||||
description: "Use 24-hour format instead of 12-hour format",
|
||||
},
|
||||
isLocaleTime: {
|
||||
label: "Use locale time",
|
||||
},
|
||||
timezone: {
|
||||
label: "Timezone",
|
||||
},
|
||||
},
|
||||
},
|
||||
weather: {
|
||||
option: {
|
||||
location: {
|
||||
label: "Location",
|
||||
},
|
||||
showCity: {
|
||||
label: "Show city",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
common: {
|
||||
search: {
|
||||
placeholder: "Search for anything...",
|
||||
nothingFound: "Nothing found",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
1
packages/widgets/index.ts
Normal file
1
packages/widgets/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
42
packages/widgets/package.json
Normal file
42
packages/widgets/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@homarr/widgets",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^8.53.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"@homarr/eslint-config/base"
|
||||
]
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0"
|
||||
}
|
||||
}
|
||||
71
packages/widgets/src/WidgetEditModal.tsx
Normal file
71
packages/widgets/src/WidgetEditModal.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { ManagedModal } from "mantine-modal-manager";
|
||||
|
||||
import { Button, Group, Stack } from "@homarr/ui";
|
||||
|
||||
import type { WidgetSort } from ".";
|
||||
import { getInputForType } from "./_inputs";
|
||||
import { FormProvider, useForm } from "./_inputs/form";
|
||||
import type { WidgetOptionsRecordOf } from "./definition";
|
||||
import type { WidgetOptionDefinition } from "./options";
|
||||
|
||||
interface ModalProps<TSort extends WidgetSort> {
|
||||
sort: TSort;
|
||||
state: [
|
||||
Record<string, unknown>,
|
||||
Dispatch<SetStateAction<Record<string, unknown>>>,
|
||||
];
|
||||
definition: WidgetOptionsRecordOf<TSort>;
|
||||
}
|
||||
|
||||
export const WidgetEditModal: ManagedModal<ModalProps<WidgetSort>> = ({
|
||||
actions,
|
||||
innerProps,
|
||||
}) => {
|
||||
const [value, setValue] = innerProps.state;
|
||||
const form = useForm({
|
||||
initialValues: value,
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((v) => {
|
||||
setValue(v);
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<FormProvider form={form}>
|
||||
<Stack>
|
||||
{Object.entries(innerProps.definition).map(
|
||||
([key, value]: [string, WidgetOptionDefinition]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
|
||||
if (!Input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={key}
|
||||
sort={innerProps.sort}
|
||||
property={key}
|
||||
options={value as never}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
<Group justify="right">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
Close
|
||||
</Button>
|
||||
<Button type="submit" color="teal">
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
33
packages/widgets/src/_inputs/common.tsx
Normal file
33
packages/widgets/src/_inputs/common.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetSort } from "..";
|
||||
import type { WidgetOptionOfType, WidgetOptionType } from "../options";
|
||||
|
||||
export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
|
||||
sort: WidgetSort;
|
||||
property: string;
|
||||
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
|
||||
}
|
||||
|
||||
type UseWidgetInputTranslationReturnType = (
|
||||
key: "label" | "description",
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* Short description why as and unknown convertions are used below:
|
||||
* Typescript was not smart enought to work with the generic of the WidgetSort to only allow properties that are relying within that specified sort.
|
||||
* This does not mean, that the function useWidgetInputTranslation can be called with invalid arguments without type errors and rather means that the above widget.<sort>.option.<property> string
|
||||
* is not recognized as valid argument for the scoped i18n hook. Because the typesafety should remain outside the usage of those methods I (Meierschlumpf) decided to provide this fully typesafe useWidgetInputTranslation method.
|
||||
*
|
||||
* Some notes about it:
|
||||
* - The label translation can be used for every input, especially considering that all options should have defined a label for themself. The description translation should only be used when withDescription
|
||||
* is defined for the option. The method does sadly not reconize issues with those definitions. So it does not yell at you when you somewhere show the label without having it defined in the translations.
|
||||
*/
|
||||
export const useWidgetInputTranslation = (
|
||||
sort: WidgetSort,
|
||||
property: string,
|
||||
): UseWidgetInputTranslationReturnType => {
|
||||
return useScopedI18n(
|
||||
`widget.${sort}.option.${property}` as never, // Because the type is complex and not recognized by typescript, we need to cast it to never to make it work.
|
||||
) as unknown as UseWidgetInputTranslationReturnType;
|
||||
};
|
||||
6
packages/widgets/src/_inputs/form.ts
Normal file
6
packages/widgets/src/_inputs/form.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createFormContext } from "@homarr/form";
|
||||
|
||||
export const [FormProvider, useFormContext, useForm] =
|
||||
createFormContext<Record<string, unknown>>();
|
||||
24
packages/widgets/src/_inputs/index.ts
Normal file
24
packages/widgets/src/_inputs/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { WidgetOptionType } from "../options";
|
||||
import { WidgetMultiSelectInput } from "./widget-multiselect-input";
|
||||
import { WidgetNumberInput } from "./widget-number-input";
|
||||
import { WidgetSelectInput } from "./widget-select-input";
|
||||
import { WidgetSliderInput } from "./widget-slider-input";
|
||||
import { WidgetSwitchInput } from "./widget-switch-input";
|
||||
import { WidgetTextInput } from "./widget-text-input";
|
||||
|
||||
const mapping = {
|
||||
text: WidgetTextInput,
|
||||
location: () => null,
|
||||
multiSelect: WidgetMultiSelectInput,
|
||||
multiText: () => null,
|
||||
number: WidgetNumberInput,
|
||||
select: WidgetSelectInput,
|
||||
slider: WidgetSliderInput,
|
||||
switch: WidgetSwitchInput,
|
||||
} satisfies Record<WidgetOptionType, unknown>;
|
||||
|
||||
export const getInputForType = <TType extends WidgetOptionType>(
|
||||
type: TType,
|
||||
) => {
|
||||
return mapping[type];
|
||||
};
|
||||
25
packages/widgets/src/_inputs/widget-multiselect-input.tsx
Normal file
25
packages/widgets/src/_inputs/widget-multiselect-input.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { MultiSelect } from "@homarr/ui";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetMultiSelectInput = ({
|
||||
property,
|
||||
sort,
|
||||
options,
|
||||
}: CommonWidgetInputProps<"multiSelect">) => {
|
||||
const t = useWidgetInputTranslation(sort, property);
|
||||
const form = useFormContext();
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
label={t("label")}
|
||||
data={options.options as unknown as string[]}
|
||||
description={options.withDescription ? t("description") : undefined}
|
||||
{...form.getInputProps(property)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
27
packages/widgets/src/_inputs/widget-number-input.tsx
Normal file
27
packages/widgets/src/_inputs/widget-number-input.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { NumberInput } from "@homarr/ui";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetNumberInput = ({
|
||||
property,
|
||||
sort,
|
||||
options,
|
||||
}: CommonWidgetInputProps<"number">) => {
|
||||
const t = useWidgetInputTranslation(sort, property);
|
||||
const form = useFormContext();
|
||||
|
||||
return (
|
||||
<NumberInput
|
||||
label={t("label")}
|
||||
description={options.withDescription ? t("description") : undefined}
|
||||
min={options.validate.minValue ?? undefined}
|
||||
max={options.validate.maxValue ?? undefined}
|
||||
step={options.step}
|
||||
{...form.getInputProps(property)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
25
packages/widgets/src/_inputs/widget-select-input.tsx
Normal file
25
packages/widgets/src/_inputs/widget-select-input.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { Select } from "@homarr/ui";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetSelectInput = ({
|
||||
property,
|
||||
sort,
|
||||
options,
|
||||
}: CommonWidgetInputProps<"select">) => {
|
||||
const t = useWidgetInputTranslation(sort, property);
|
||||
const form = useFormContext();
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={t("label")}
|
||||
data={options.options as unknown as string[]}
|
||||
description={options.withDescription ? t("description") : undefined}
|
||||
{...form.getInputProps(property)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
30
packages/widgets/src/_inputs/widget-slider-input.tsx
Normal file
30
packages/widgets/src/_inputs/widget-slider-input.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { InputWrapper, Slider } from "@homarr/ui";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetSliderInput = ({
|
||||
property,
|
||||
sort,
|
||||
options,
|
||||
}: CommonWidgetInputProps<"slider">) => {
|
||||
const t = useWidgetInputTranslation(sort, property);
|
||||
const form = useFormContext();
|
||||
|
||||
return (
|
||||
<InputWrapper
|
||||
description={options.withDescription ? t("description") : undefined}
|
||||
>
|
||||
<Slider
|
||||
label={t("label")}
|
||||
min={options.validate.minValue ?? undefined}
|
||||
max={options.validate.maxValue ?? undefined}
|
||||
step={options.step}
|
||||
{...form.getInputProps(property)}
|
||||
/>
|
||||
</InputWrapper>
|
||||
);
|
||||
};
|
||||
24
packages/widgets/src/_inputs/widget-switch-input.tsx
Normal file
24
packages/widgets/src/_inputs/widget-switch-input.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { Switch } from "@homarr/ui";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetSwitchInput = ({
|
||||
property,
|
||||
sort,
|
||||
options,
|
||||
}: CommonWidgetInputProps<"switch">) => {
|
||||
const t = useWidgetInputTranslation(sort, property);
|
||||
const form = useFormContext();
|
||||
|
||||
return (
|
||||
<Switch
|
||||
label={t("label")}
|
||||
description={options.withDescription ? t("description") : undefined}
|
||||
{...form.getInputProps(property, { type: "checkbox" })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
24
packages/widgets/src/_inputs/widget-text-input.tsx
Normal file
24
packages/widgets/src/_inputs/widget-text-input.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { TextInput } from "@homarr/ui";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetTextInput = ({
|
||||
property,
|
||||
sort: widgetSort,
|
||||
options,
|
||||
}: CommonWidgetInputProps<"text">) => {
|
||||
const t = useWidgetInputTranslation(widgetSort, property);
|
||||
const form = useFormContext();
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
label={t("label")}
|
||||
description={options.withDescription ? t("description") : undefined}
|
||||
{...form.getInputProps(property)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
7
packages/widgets/src/clock/component.tsx
Normal file
7
packages/widgets/src/clock/component.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
export default function ClockWidget({
|
||||
options,
|
||||
}: WidgetComponentProps<"clock">) {
|
||||
return <pre>{JSON.stringify(options)}</pre>;
|
||||
}
|
||||
26
packages/widgets/src/clock/index.ts
Normal file
26
packages/widgets/src/clock/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { IconClock } from "@homarr/ui";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { opt } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
icon: IconClock,
|
||||
options: opt.from(
|
||||
(fac) => ({
|
||||
is24HourFormat: fac.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
isLocaleTime: fac.switch({ defaultValue: true }),
|
||||
timezone: fac.select({
|
||||
options: ["Europe/Berlin", "Europe/London", "Europe/Moscow"] as const,
|
||||
defaultValue: "Europe/Berlin",
|
||||
}),
|
||||
}),
|
||||
{
|
||||
timezone: {
|
||||
shouldHide: (options) => options.isLocaleTime,
|
||||
},
|
||||
},
|
||||
),
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
40
packages/widgets/src/definition.ts
Normal file
40
packages/widgets/src/definition.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { LoaderComponent } from "next/dynamic";
|
||||
|
||||
import type { TablerIconsProps } from "@homarr/ui";
|
||||
|
||||
import type { WidgetImports, WidgetSort } from ".";
|
||||
import type {
|
||||
inferOptionsFromDefinition,
|
||||
WidgetOptionsRecord,
|
||||
} from "./options";
|
||||
|
||||
export const createWidgetDefinition = <
|
||||
TSort extends WidgetSort,
|
||||
TDefinition extends Definition,
|
||||
>(
|
||||
sort: TSort,
|
||||
definition: TDefinition,
|
||||
) => ({
|
||||
withDynamicImport: (
|
||||
componentLoader: () => LoaderComponent<WidgetComponentProps<TSort>>,
|
||||
) => ({
|
||||
definition: {
|
||||
sort,
|
||||
...definition,
|
||||
},
|
||||
componentLoader,
|
||||
}),
|
||||
});
|
||||
|
||||
interface Definition {
|
||||
icon: (props: TablerIconsProps) => JSX.Element;
|
||||
options: WidgetOptionsRecord;
|
||||
}
|
||||
|
||||
export interface WidgetComponentProps<TSort extends WidgetSort> {
|
||||
options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TSort>>;
|
||||
integrations: unknown[];
|
||||
}
|
||||
|
||||
export type WidgetOptionsRecordOf<TSort extends WidgetSort> =
|
||||
WidgetImports[TSort]["definition"]["options"];
|
||||
5
packages/widgets/src/import.ts
Normal file
5
packages/widgets/src/import.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { WidgetSort } from ".";
|
||||
|
||||
export type WidgetImportRecord = {
|
||||
[K in WidgetSort]: unknown;
|
||||
};
|
||||
30
packages/widgets/src/index.tsx
Normal file
30
packages/widgets/src/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import type { Loader } from "next/dynamic";
|
||||
|
||||
import { Loader as UiLoader } from "@homarr/ui";
|
||||
|
||||
import * as clock from "./clock";
|
||||
import type { WidgetComponentProps } from "./definition";
|
||||
import type { WidgetImportRecord } from "./import";
|
||||
import * as weather from "./weather";
|
||||
|
||||
export { WidgetEditModal } from "./WidgetEditModal";
|
||||
|
||||
export const widgetSorts = ["clock", "weather"] as const;
|
||||
|
||||
export const widgetImports = {
|
||||
clock,
|
||||
weather,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetSort = (typeof widgetSorts)[number];
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
export type WidgetImportKey = keyof WidgetImports;
|
||||
|
||||
export const loadWidgetDynamic = <TSort extends WidgetSort>(sort: TSort) =>
|
||||
dynamic<WidgetComponentProps<TSort>>(
|
||||
widgetImports[sort].componentLoader as Loader<WidgetComponentProps<TSort>>,
|
||||
{
|
||||
loading: () => <UiLoader />,
|
||||
},
|
||||
);
|
||||
142
packages/widgets/src/options.ts
Normal file
142
packages/widgets/src/options.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { z } from "@homarr/validation";
|
||||
|
||||
interface CommonInput<TType> {
|
||||
defaultValue?: TType;
|
||||
withDescription?: boolean;
|
||||
}
|
||||
|
||||
interface TextInput extends CommonInput<string> {
|
||||
validate: z.ZodType<string>;
|
||||
}
|
||||
|
||||
interface MultiSelectInput<TOptions extends string[]>
|
||||
extends CommonInput<TOptions[number][]> {
|
||||
options: TOptions;
|
||||
}
|
||||
|
||||
interface SelectInput<TOptions extends readonly [string, ...string[]]>
|
||||
extends CommonInput<TOptions[number]> {
|
||||
options: TOptions;
|
||||
}
|
||||
|
||||
interface NumberInput extends CommonInput<number | ""> {
|
||||
validate: z.ZodNumber;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
interface SliderInput extends CommonInput<number> {
|
||||
validate: z.ZodNumber;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
interface OptLocation {
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
const optionsFactory = {
|
||||
switch: (input?: CommonInput<boolean>) => ({
|
||||
type: "switch" as const,
|
||||
defaultValue: input?.defaultValue ?? false,
|
||||
withDescription: input?.withDescription ?? false,
|
||||
}),
|
||||
text: (input?: TextInput) => ({
|
||||
type: "text" as const,
|
||||
defaultValue: input?.defaultValue ?? "",
|
||||
withDescription: input?.withDescription ?? false,
|
||||
validate: input?.validate,
|
||||
}),
|
||||
multiSelect: <TOptions extends string[]>(
|
||||
input: MultiSelectInput<TOptions>,
|
||||
) => ({
|
||||
type: "multiSelect" as const,
|
||||
defaultValue: input.defaultValue ?? [],
|
||||
options: input.options,
|
||||
withDescription: input.withDescription ?? false,
|
||||
}),
|
||||
select: <TOptions extends readonly [string, ...string[]]>(
|
||||
input: SelectInput<TOptions>,
|
||||
) => ({
|
||||
type: "select" as const,
|
||||
defaultValue: input.defaultValue ?? input.options[0],
|
||||
options: input.options,
|
||||
withDescription: input.withDescription ?? false,
|
||||
}),
|
||||
number: (input: NumberInput) => ({
|
||||
type: "number" as const,
|
||||
defaultValue: input.defaultValue ?? ("" as const),
|
||||
step: input.step,
|
||||
withDescription: input.withDescription ?? false,
|
||||
validate: input.validate,
|
||||
}),
|
||||
slider: (input: SliderInput) => ({
|
||||
type: "slider" as const,
|
||||
defaultValue: input.defaultValue ?? input.validate.minValue ?? 0,
|
||||
step: input.step,
|
||||
withDescription: input.withDescription ?? false,
|
||||
validate: input.validate,
|
||||
}),
|
||||
location: (input?: CommonInput<OptLocation>) => ({
|
||||
type: "location" as const,
|
||||
defaultValue: input?.defaultValue ?? {
|
||||
name: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
},
|
||||
withDescription: input?.withDescription ?? false,
|
||||
}),
|
||||
multiText: (input?: CommonInput<string[]>) => ({
|
||||
type: "multiText" as const,
|
||||
defaultValue: input?.defaultValue ?? [],
|
||||
withDescription: input?.withDescription ?? false,
|
||||
}),
|
||||
};
|
||||
|
||||
type WidgetOptionFactory = typeof optionsFactory;
|
||||
export type WidgetOptionDefinition = ReturnType<
|
||||
WidgetOptionFactory[keyof WidgetOptionFactory]
|
||||
>;
|
||||
export type WidgetOptionsRecord = Record<string, WidgetOptionDefinition>;
|
||||
export type WidgetOptionType = WidgetOptionDefinition["type"];
|
||||
export type WidgetOptionOfType<TType extends WidgetOptionType> = Extract<
|
||||
WidgetOptionDefinition,
|
||||
{ type: TType }
|
||||
>;
|
||||
|
||||
type inferOptionFromDefinition<TDefinition extends WidgetOptionDefinition> =
|
||||
TDefinition["defaultValue"];
|
||||
export type inferOptionsFromDefinition<TOptions extends WidgetOptionsRecord> = {
|
||||
[key in keyof TOptions]: inferOptionFromDefinition<TOptions[key]>;
|
||||
};
|
||||
|
||||
interface FieldConfiguration<TOptions extends WidgetOptionsRecord> {
|
||||
shouldHide: (options: inferOptionsFromDefinition<TOptions>) => boolean;
|
||||
}
|
||||
|
||||
type ConfigurationInput<TOptions extends WidgetOptionsRecord> = Partial<
|
||||
Record<keyof TOptions, FieldConfiguration<TOptions>>
|
||||
>;
|
||||
|
||||
const createOptions = <TOptions extends WidgetOptionsRecord>(
|
||||
optionsCallback: (factory: WidgetOptionFactory) => TOptions,
|
||||
configuration?: ConfigurationInput<TOptions>,
|
||||
) => {
|
||||
const obj = {} as Record<keyof TOptions, unknown>;
|
||||
const options = optionsCallback(optionsFactory);
|
||||
|
||||
for (const key in options) {
|
||||
obj[key] = {
|
||||
...configuration?.[key],
|
||||
...options[key],
|
||||
};
|
||||
}
|
||||
|
||||
return obj as {
|
||||
[key in keyof TOptions]: TOptions[key] & FieldConfiguration<TOptions>;
|
||||
};
|
||||
};
|
||||
|
||||
export const opt = {
|
||||
from: createOptions,
|
||||
};
|
||||
7
packages/widgets/src/weather/component.tsx
Normal file
7
packages/widgets/src/weather/component.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
export default function WeatherWidget({
|
||||
options,
|
||||
}: WidgetComponentProps<"weather">) {
|
||||
return <pre>{JSON.stringify(options)}</pre>;
|
||||
}
|
||||
15
packages/widgets/src/weather/index.ts
Normal file
15
packages/widgets/src/weather/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { IconCloud } from "@homarr/ui";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { opt } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition(
|
||||
"weather",
|
||||
{
|
||||
icon: IconCloud,
|
||||
options: opt.from((fac) => ({
|
||||
location: fac.location(),
|
||||
showCity: fac.switch(),
|
||||
})),
|
||||
},
|
||||
).withDynamicImport(() => import("./component"));
|
||||
8
packages/widgets/tsconfig.json
Normal file
8
packages/widgets/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
131
pnpm-lock.yaml
generated
131
pnpm-lock.yaml
generated
@@ -56,12 +56,15 @@ importers:
|
||||
'@homarr/validation':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/validation
|
||||
'@homarr/widgets':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/widgets
|
||||
'@mantine/hooks':
|
||||
specifier: ^7.3.1
|
||||
version: 7.3.1(react@18.2.0)
|
||||
specifier: ^7.3.2
|
||||
version: 7.3.2(react@18.2.0)
|
||||
'@mantine/tiptap':
|
||||
specifier: ^7.3.1
|
||||
version: 7.3.1(@mantine/core@7.3.1)(@mantine/hooks@7.3.1)(@tabler/icons-react@2.42.0)(@tiptap/extension-link@2.1.13)(react-dom@18.2.0)(react@18.2.0)
|
||||
specifier: ^7.3.2
|
||||
version: 7.3.2(@mantine/core@7.3.2)(@mantine/hooks@7.3.2)(@tabler/icons-react@2.42.0)(@tiptap/extension-link@2.1.13)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@t3-oss/env-nextjs':
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1(typescript@5.3.3)(zod@3.22.4)
|
||||
@@ -98,6 +101,12 @@ importers:
|
||||
dayjs:
|
||||
specifier: ^1.11.10
|
||||
version: 1.11.10
|
||||
jotai:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0(@types/react@18.2.42)(react@18.2.0)
|
||||
mantine-modal-manager:
|
||||
specifier: ^7.3.2
|
||||
version: 7.3.2(@mantine/core@7.3.2)(@mantine/hooks@7.3.2)(react-dom@18.2.0)(react@18.2.0)
|
||||
next:
|
||||
specifier: ^14.0.3
|
||||
version: 14.0.3(react-dom@18.2.0)(react@18.2.0)
|
||||
@@ -381,7 +390,7 @@ importers:
|
||||
dependencies:
|
||||
'@mantine/core':
|
||||
specifier: ^7.3.2
|
||||
version: 7.3.2(@mantine/hooks@7.3.2)(react-dom@18.2.0)(react@18.2.0)
|
||||
version: 7.3.2(@mantine/hooks@7.3.2)(@types/react@18.2.42)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@mantine/dates':
|
||||
specifier: 7.3.2
|
||||
version: 7.3.2(@mantine/core@7.3.2)(@mantine/hooks@7.3.2)(dayjs@1.11.10)(react-dom@18.2.0)(react@18.2.0)
|
||||
@@ -427,6 +436,40 @@ importers:
|
||||
specifier: ^5.3.3
|
||||
version: 5.3.3
|
||||
|
||||
packages/widgets:
|
||||
dependencies:
|
||||
'@homarr/form':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../form
|
||||
'@homarr/notifications':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../notifications
|
||||
'@homarr/translation':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../translation
|
||||
'@homarr/ui':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../ui
|
||||
'@homarr/validation':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../validation
|
||||
devDependencies:
|
||||
'@homarr/eslint-config':
|
||||
specifier: workspace:^0.2.0
|
||||
version: link:../../tooling/eslint
|
||||
'@homarr/prettier-config':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../tooling/prettier
|
||||
'@homarr/tsconfig':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../tooling/typescript
|
||||
eslint:
|
||||
specifier: ^8.53.0
|
||||
version: 8.53.0
|
||||
typescript:
|
||||
specifier: ^5.3.3
|
||||
version: 5.3.3
|
||||
|
||||
tooling/eslint:
|
||||
dependencies:
|
||||
'@next/eslint-plugin-next':
|
||||
@@ -1383,27 +1426,7 @@ packages:
|
||||
'@jridgewell/sourcemap-codec': 1.4.15
|
||||
dev: true
|
||||
|
||||
/@mantine/core@7.3.1(@mantine/hooks@7.3.1)(@types/react@18.2.42)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-WIesapVzK1ERFcALuosaEPuODN/k/FGkryf2d12R7vsDmxmWqX6yNzPUoJDy6j20ueAkyyg4beJZ4PuZfCRW9Q==}
|
||||
peerDependencies:
|
||||
'@mantine/hooks': 7.3.1
|
||||
react: ^18.2.0
|
||||
react-dom: ^18.2.0
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.24.8(react-dom@18.2.0)(react@18.2.0)
|
||||
'@mantine/hooks': 7.3.1(react@18.2.0)
|
||||
clsx: 2.0.0
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
react-number-format: 5.3.1(react-dom@18.2.0)(react@18.2.0)
|
||||
react-remove-scroll: 2.5.7(@types/react@18.2.42)(react@18.2.0)
|
||||
react-textarea-autosize: 8.5.3(@types/react@18.2.42)(react@18.2.0)
|
||||
type-fest: 3.13.1
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
dev: false
|
||||
|
||||
/@mantine/core@7.3.2(@mantine/hooks@7.3.2)(react-dom@18.2.0)(react@18.2.0):
|
||||
/@mantine/core@7.3.2(@mantine/hooks@7.3.2)(@types/react@18.2.42)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-CwAuQogVLcLR7O9e1eOgi3gtk4XX6cnaqevAxzJJpIOIyCnHiQ3cEGINVXyUUjUUipBlvK3sqz3NPGJ2ekLFDQ==}
|
||||
peerDependencies:
|
||||
'@mantine/hooks': 7.3.2
|
||||
@@ -1432,7 +1455,7 @@ packages:
|
||||
react: ^18.2.0
|
||||
react-dom: ^18.2.0
|
||||
dependencies:
|
||||
'@mantine/core': 7.3.2(@mantine/hooks@7.3.2)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@mantine/core': 7.3.2(@mantine/hooks@7.3.2)(@types/react@18.2.42)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@mantine/hooks': 7.3.2(react@18.2.0)
|
||||
clsx: 2.0.0
|
||||
dayjs: 1.11.10
|
||||
@@ -1450,14 +1473,6 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@mantine/hooks@7.3.1(react@18.2.0):
|
||||
resolution: {integrity: sha512-pbbqPpVou/13xbt/dYYNphPpbDE2XfPN9mUHBoGZgv9FM8IkziNMIOo4PtNlqqqYsyp1lfQIQVKKT+DLZt1C8Q==}
|
||||
peerDependencies:
|
||||
react: ^18.2.0
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@mantine/hooks@7.3.2(react@18.2.0):
|
||||
resolution: {integrity: sha512-xgumuuI3PBWXff5N02HCI7PEy25mDEdyXDQklUYK93J6FKwpcosyZnGVitoUrV1gLtYYa9ZudeAWdhHuh/CpOg==}
|
||||
peerDependencies:
|
||||
@@ -1474,7 +1489,7 @@ packages:
|
||||
react: ^18.2.0
|
||||
react-dom: ^18.2.0
|
||||
dependencies:
|
||||
'@mantine/core': 7.3.2(@mantine/hooks@7.3.2)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@mantine/core': 7.3.2(@mantine/hooks@7.3.2)(@types/react@18.2.42)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@mantine/hooks': 7.3.2(react@18.2.0)
|
||||
'@mantine/store': 7.3.2(react@18.2.0)
|
||||
react: 18.2.0
|
||||
@@ -1490,7 +1505,7 @@ packages:
|
||||
react: ^18.2.0
|
||||
react-dom: ^18.2.0
|
||||
dependencies:
|
||||
'@mantine/core': 7.3.2(@mantine/hooks@7.3.2)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@mantine/core': 7.3.2(@mantine/hooks@7.3.2)(@types/react@18.2.42)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@mantine/hooks': 7.3.2(react@18.2.0)
|
||||
'@mantine/store': 7.3.2(react@18.2.0)
|
||||
react: 18.2.0
|
||||
@@ -1505,18 +1520,18 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@mantine/tiptap@7.3.1(@mantine/core@7.3.1)(@mantine/hooks@7.3.1)(@tabler/icons-react@2.42.0)(@tiptap/extension-link@2.1.13)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-4ISRkyyo3IcK+G/Fwm+rpX0KaYAF2b0u/HRbx9K0SQkDekhTrJvFqMIbd1wBfeSrEFCxcy42gYCGZ+3tihNxew==}
|
||||
/@mantine/tiptap@7.3.2(@mantine/core@7.3.2)(@mantine/hooks@7.3.2)(@tabler/icons-react@2.42.0)(@tiptap/extension-link@2.1.13)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-bJF924LhFL0z4bqUSGRTBd9ZR4fJFJObTPwlzJQaY9s8cB9rJaWIBSHp6OmdSvqS5KNpDFfB4tardSKOQleBcw==}
|
||||
peerDependencies:
|
||||
'@mantine/core': 7.3.1
|
||||
'@mantine/hooks': 7.3.1
|
||||
'@mantine/core': 7.3.2
|
||||
'@mantine/hooks': 7.3.2
|
||||
'@tabler/icons-react': '>=2.0.0'
|
||||
'@tiptap/extension-link': ^2.1.12
|
||||
react: ^18.2.0
|
||||
react-dom: ^18.2.0
|
||||
dependencies:
|
||||
'@mantine/core': 7.3.1(@mantine/hooks@7.3.1)(@types/react@18.2.42)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@mantine/hooks': 7.3.1(react@18.2.0)
|
||||
'@mantine/core': 7.3.2(@mantine/hooks@7.3.2)(@types/react@18.2.42)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@mantine/hooks': 7.3.2(react@18.2.0)
|
||||
'@tabler/icons-react': 2.42.0(react@18.2.0)
|
||||
'@tiptap/extension-link': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13)
|
||||
react: 18.2.0
|
||||
@@ -4788,6 +4803,22 @@ packages:
|
||||
resolution: {integrity: sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==}
|
||||
dev: false
|
||||
|
||||
/jotai@2.6.0(@types/react@18.2.42)(react@18.2.0):
|
||||
resolution: {integrity: sha512-Vt6hsc04Km4j03l+Ax+Sc+FVft5cRJhqgxt6GTz6GM2eM3DyX3CdBdzcG0z2FrlZToL1/0OAkqDghIyARWnSuQ==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=17.0.0'
|
||||
react: '>=17.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 18.2.42
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
dev: false
|
||||
@@ -4990,6 +5021,20 @@ packages:
|
||||
/make-error@1.3.6:
|
||||
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
||||
|
||||
/mantine-modal-manager@7.3.2(@mantine/core@7.3.2)(@mantine/hooks@7.3.2)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-rHt53WBYPB1b+ZlhoYYH3iwG4cZ/UuQd/nVgo+k2f/dbNQskNcRW6ZJiJevcKqndXj/ut2qkgxtppKpUD9eWgg==}
|
||||
peerDependencies:
|
||||
'@mantine/core': 7.3.2
|
||||
'@mantine/hooks': 7.3.2
|
||||
react: ^18.2.0
|
||||
react-dom: ^18.2.0
|
||||
dependencies:
|
||||
'@mantine/core': 7.3.2(@mantine/hooks@7.3.2)(@types/react@18.2.42)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@mantine/hooks': 7.3.2(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/markdown-it@13.0.2:
|
||||
resolution: {integrity: sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w==}
|
||||
hasBin: true
|
||||
|
||||
Reference in New Issue
Block a user