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:
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user