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:
Meier Lukas
2024-01-02 15:36:59 +01:00
committed by GitHub
parent fa19966fcc
commit 782897527f
48 changed files with 1226 additions and 81 deletions

View 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>
);
};

View 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" />
);
};

View 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;
}
}

View 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>
);
};

View 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}
/>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View 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;

View 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>
);
};

View 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>
);
};