mirror of
https://github.com/ajnart/homarr.git
synced 2026-01-30 11:19:12 +01:00
🚧 Add working loading of board, add temporary input to manage page to add boards
This commit is contained in:
22
package.json
22
package.json
@@ -35,16 +35,16 @@
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/server": "^11.10.0",
|
||||
"@jellyfin/sdk": "^0.8.0",
|
||||
"@mantine/core": "^6.0.0",
|
||||
"@mantine/dates": "^6.0.0",
|
||||
"@mantine/dropzone": "^6.0.0",
|
||||
"@mantine/form": "^6.0.0",
|
||||
"@mantine/hooks": "^6.0.0",
|
||||
"@mantine/modals": "^6.0.0",
|
||||
"@mantine/next": "^6.0.0",
|
||||
"@mantine/notifications": "^6.0.0",
|
||||
"@mantine/prism": "^6.0.19",
|
||||
"@mantine/tiptap": "^6.0.17",
|
||||
"@mantine/core": "^6.0.21",
|
||||
"@mantine/dates": "^6.0.21",
|
||||
"@mantine/dropzone": "^6.0.21",
|
||||
"@mantine/form": "^6.0.21",
|
||||
"@mantine/hooks": "^6.0.21",
|
||||
"@mantine/modals": "^6.0.21",
|
||||
"@mantine/next": "^6.0.21",
|
||||
"@mantine/notifications": "^6.0.21",
|
||||
"@mantine/prism": "^6.0.21",
|
||||
"@mantine/tiptap": "^6.0.21",
|
||||
"@nivo/core": "^0.83.0",
|
||||
"@nivo/line": "^0.83.0",
|
||||
"@react-native-async-storage/async-storage": "^1.18.1",
|
||||
@@ -250,4 +250,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
65
src/components/Board/context.tsx
Normal file
65
src/components/Board/context.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { RouterOutputs, api } from '~/utils/api';
|
||||
|
||||
type BoardContextType = {
|
||||
layout?: string;
|
||||
board: RouterOutputs['boards']['byName'];
|
||||
};
|
||||
const BoardContext = createContext<BoardContextType | null>(null);
|
||||
type BoardProviderProps = {
|
||||
initialBoard: RouterOutputs['boards']['byName'];
|
||||
layout?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
export const BoardProvider = ({ children, ...props }: BoardProviderProps) => {
|
||||
const { data: board } = api.boards.byName.useQuery(
|
||||
{
|
||||
boardName: props.initialBoard.name,
|
||||
layout: props.layout,
|
||||
},
|
||||
{
|
||||
initialData: props.initialBoard,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<BoardContext.Provider
|
||||
value={{
|
||||
...props,
|
||||
board: board!,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BoardContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useRequiredBoard = () => {
|
||||
const ctx = useContext(BoardContext);
|
||||
if (!ctx) throw new Error('useBoard must be used within a BoardProvider');
|
||||
return ctx.board;
|
||||
};
|
||||
|
||||
export const useOptionalBoard = () => {
|
||||
const ctx = useContext(BoardContext);
|
||||
return ctx?.board ?? null;
|
||||
};
|
||||
|
||||
export type Section = RouterOutputs['boards']['byName']['sections'][number];
|
||||
type SectionOfType<
|
||||
TSection extends Section,
|
||||
TSectionType extends Section['type'],
|
||||
> = TSection extends { type: TSectionType } ? TSection : never;
|
||||
export type CategorySection = SectionOfType<Section, 'category'>;
|
||||
export type EmptySection = SectionOfType<Section, 'empty'>;
|
||||
export type SidebarSection = SectionOfType<Section, 'sidebar'>;
|
||||
export type HiddenSection = SectionOfType<Section, 'hidden'>;
|
||||
|
||||
export type Item = Section['items'][number];
|
||||
type ItemOfType<TItem extends Item, TItemType extends Item['type']> = TItem extends {
|
||||
type: TItemType;
|
||||
}
|
||||
? TItem
|
||||
: never;
|
||||
export type AppItem = ItemOfType<Item, 'app'>;
|
||||
export type WidgetItem = ItemOfType<Item, 'widget'>;
|
||||
@@ -1,15 +1,16 @@
|
||||
import { MobileRibbons } from './Mobile/Ribbon/MobileRibbon';
|
||||
import { BoardView } from './Views/DashboardView';
|
||||
import { DashboardDetailView } from './Views/DetailView';
|
||||
import { DashboardEditView } from './Views/EditView';
|
||||
import { useEditModeStore } from './Views/useEditModeStore';
|
||||
|
||||
export const Dashboard = () => {
|
||||
export const Board = () => {
|
||||
const isEditMode = useEditModeStore((x) => x.enabled);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The following elemens are splitted because gridstack doesn't reinitialize them when using same item. */}
|
||||
{isEditMode ? <DashboardEditView /> : <DashboardDetailView />}
|
||||
<BoardView key={isEditMode.toString()} />
|
||||
<MobileRibbons />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,19 +4,19 @@ import Consola from 'consola';
|
||||
import { TargetAndTransition, Transition, motion } from 'framer-motion';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { RouterOutputs, api } from '~/utils/api';
|
||||
|
||||
import { AppItem } from '~/components/Board/context';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { AppType } from '~/types/app';
|
||||
import { RouterOutputs, api } from '~/utils/api';
|
||||
|
||||
interface AppPingProps {
|
||||
app: AppType;
|
||||
app: AppItem;
|
||||
}
|
||||
|
||||
export const AppPing = ({ app }: AppPingProps) => {
|
||||
const { data: sessionData } = useSession();
|
||||
const { data: userWithSettings } = api.user.withSettings.useQuery(undefined, {
|
||||
enabled: app.network.enabledStatusChecker && !!sessionData?.user,
|
||||
enabled: app.isPingEnabled && !!sessionData?.user,
|
||||
});
|
||||
|
||||
const { data, isFetching, isError, error, isActive } = usePing(app);
|
||||
@@ -74,13 +74,6 @@ const AccessibleIndicatorPing = ({ isFetching, isOnline }: AccessibleIndicatorPi
|
||||
return <IconX color="red" />;
|
||||
};
|
||||
|
||||
export const isStatusOk = (app: AppType, status: number) => {
|
||||
if (app.network.okStatus === undefined || app.network.statusCodes.length >= 1) {
|
||||
return app.network.statusCodes.includes(status.toString());
|
||||
}
|
||||
return app.network.okStatus.includes(status);
|
||||
};
|
||||
|
||||
type TooltipLabelProps = {
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
@@ -97,11 +90,10 @@ const useTooltipLabel = ({ isFetching, isError, data, errorMessage }: TooltipLab
|
||||
return `${data?.statusText}: ${data?.status} (denied)`;
|
||||
};
|
||||
|
||||
const usePing = (app: AppType) => {
|
||||
const usePing = (app: AppItem) => {
|
||||
const { config, name } = useConfigContext();
|
||||
const isActive =
|
||||
(config?.settings.customization.layout.enabledPing && app.network.enabledStatusChecker) ??
|
||||
false;
|
||||
(config?.settings.customization.layout.enabledPing && app.isPingEnabled) ?? false;
|
||||
|
||||
const queryResult = api.app.ping.useQuery(
|
||||
{
|
||||
@@ -122,11 +114,15 @@ const usePing = (app: AppType) => {
|
||||
retryOnMount: true,
|
||||
|
||||
select: (data) => {
|
||||
const isOk = isStatusOk(app, data.status);
|
||||
const isOk = app.statusCodes.includes(data.status);
|
||||
if (isOk)
|
||||
Consola.info(`Ping of app "${app.name}" (${app.url}) returned ${data.status} (Accepted)`);
|
||||
Consola.info(
|
||||
`Ping of app "${app.name}" (${app.internalUrl}) returned ${data.status} (Accepted)`
|
||||
);
|
||||
else
|
||||
Consola.warn(`Ping of app "${app.name}" (${app.url}) returned ${data.status} (Refused)`);
|
||||
Consola.warn(
|
||||
`Ping of app "${app.name}" (${app.internalUrl}) returned ${data.status} (Refused)`
|
||||
);
|
||||
return {
|
||||
status: data.status,
|
||||
state: isOk ? ('online' as const) : ('down' as const),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Affix, Box, Text, Tooltip, UnstyledButton } from '@mantine/core';
|
||||
import { Box, Text, Tooltip, UnstyledButton } from '@mantine/core';
|
||||
import { createStyles, useMantineTheme } from '@mantine/styles';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { AppItem } from '~/components/Board/context';
|
||||
|
||||
import { AppType } from '~/types/app';
|
||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||
import { HomarrCardWrapper } from '../HomarrCardWrapper';
|
||||
import { BaseTileProps } from '../type';
|
||||
@@ -11,21 +11,25 @@ import { AppMenu } from './AppMenu';
|
||||
import { AppPing } from './AppPing';
|
||||
|
||||
interface AppTileProps extends BaseTileProps {
|
||||
app: AppType;
|
||||
app: AppItem;
|
||||
}
|
||||
|
||||
const namePositions = {
|
||||
right: 'row',
|
||||
left: 'row-reverse',
|
||||
top: 'column',
|
||||
bottom: 'column-reverse',
|
||||
};
|
||||
|
||||
export const AppTile = ({ className, app }: AppTileProps) => {
|
||||
const isEditMode = useEditModeStore((x) => x.enabled);
|
||||
const { cx, classes } = useStyles();
|
||||
const { colorScheme } = useMantineTheme();
|
||||
const tooltipContent = [
|
||||
app.appearance.appNameStatus === 'hover' ? app.name : undefined,
|
||||
app.behaviour.tooltipDescription,
|
||||
]
|
||||
const tooltipContent = [app.nameStyle === 'hover' ? app.name : undefined, app.description]
|
||||
.filter((e) => e)
|
||||
.join(': ');
|
||||
|
||||
const isRow = app.appearance.positionAppName.includes('row');
|
||||
const isRow = app.namePosition === 'right' || app.namePosition === 'left';
|
||||
|
||||
function Inner() {
|
||||
return (
|
||||
@@ -42,26 +46,26 @@ export const AppTile = ({ className, app }: AppTileProps) => {
|
||||
className={`${classes.base} ${cx(classes.appContent, 'dashboard-tile-app')}`}
|
||||
h="100%"
|
||||
sx={{
|
||||
flexFlow: app.appearance.positionAppName ?? 'column',
|
||||
flexFlow: namePositions[app.namePosition] ?? 'column',
|
||||
}}
|
||||
>
|
||||
{app.appearance.appNameStatus === 'normal' && (
|
||||
{app.nameStyle === 'show' && (
|
||||
<Text
|
||||
className={cx(classes.appName, 'dashboard-tile-app-title')}
|
||||
fw={700}
|
||||
size={app.appearance.appNameFontSize}
|
||||
size={app.fontSize}
|
||||
ta="center"
|
||||
sx={{
|
||||
flex: isRow ? '1' : undefined,
|
||||
}}
|
||||
lineClamp={app.appearance.lineClampAppName}
|
||||
lineClamp={app.nameLineClamp}
|
||||
>
|
||||
{app.name}
|
||||
</Text>
|
||||
)}
|
||||
<motion.img
|
||||
className={cx(classes.appImage, 'dashboard-tile-app-image')}
|
||||
src={app.appearance.iconUrl}
|
||||
src={app.iconUrl!}
|
||||
alt={app.name}
|
||||
whileHover={{ scale: 0.9 }}
|
||||
initial={{ scale: 0.8 }}
|
||||
@@ -74,10 +78,12 @@ export const AppTile = ({ className, app }: AppTileProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
const url = app.externalUrl ? app.externalUrl : app.internalUrl;
|
||||
|
||||
return (
|
||||
<HomarrCardWrapper className={className} p={10}>
|
||||
<AppMenu app={app} />
|
||||
{!app.url || isEditMode ? (
|
||||
{!url || isEditMode ? (
|
||||
<UnstyledButton
|
||||
className={`${classes.button} ${classes.base}`}
|
||||
style={{ pointerEvents: isEditMode ? 'none' : 'auto' }}
|
||||
@@ -88,8 +94,8 @@ export const AppTile = ({ className, app }: AppTileProps) => {
|
||||
<UnstyledButton
|
||||
style={{ pointerEvents: isEditMode ? 'none' : 'auto' }}
|
||||
component={Link}
|
||||
href={app.behaviour.externalUrl.length > 0 ? app.behaviour.externalUrl : app.url}
|
||||
target={app.behaviour.isOpeningNewTab ? '_blank' : '_self'}
|
||||
href={url}
|
||||
target={app.openInNewTab ? '_blank' : '_self'}
|
||||
className={`${classes.button} ${classes.base}`}
|
||||
>
|
||||
<Inner />
|
||||
@@ -111,7 +117,7 @@ const useStyles = createStyles((theme, _params, getRef) => ({
|
||||
overflow: 'visible',
|
||||
flexGrow: 5,
|
||||
},
|
||||
appImage:{
|
||||
appImage: {
|
||||
maxHeight: '100%',
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Title } from '@mantine/core';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { WidgetItem } from '~/components/Board/context';
|
||||
import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions';
|
||||
import WidgetsDefinitions from '../../../../widgets';
|
||||
import { IWidget } from '~/widgets/widgets';
|
||||
|
||||
import WidgetsDefinitions from '../../../../widgets';
|
||||
import { useWrapperColumnCount } from '../../Wrappers/gridstack/store';
|
||||
import { GenericTileMenu } from '../GenericTileMenu';
|
||||
import { WidgetEditModalInnerProps } from './WidgetsEditModal';
|
||||
@@ -18,7 +19,7 @@ export type WidgetChangePositionModalInnerProps = {
|
||||
|
||||
interface WidgetsMenuProps {
|
||||
integration: string;
|
||||
widget: IWidget<string, any> | undefined;
|
||||
widget: WidgetItem | undefined;
|
||||
}
|
||||
|
||||
export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => {
|
||||
@@ -67,7 +68,7 @@ export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => {
|
||||
innerProps: {
|
||||
widgetId: widget.id,
|
||||
widgetType: integration,
|
||||
options: widget.properties,
|
||||
options: widget.options,
|
||||
// Cast as the right type for the correct widget
|
||||
widgetOptions: widgetDefinitionObject.options as any,
|
||||
},
|
||||
@@ -81,7 +82,7 @@ export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => {
|
||||
handleClickChangePosition={handleChangeSizeClick}
|
||||
handleClickDelete={handleDeleteClick}
|
||||
displayEdit={
|
||||
typeof widget.properties !== 'undefined' &&
|
||||
typeof widget.options !== 'undefined' &&
|
||||
Object.keys(widgetDefinitionObject?.options ?? {}).length !== 0
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { CategorySection, EmptySection, useRequiredBoard } from '~/components/Board/context';
|
||||
import { useResize } from '~/hooks/use-resize';
|
||||
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
|
||||
import { CategoryType } from '~/types/category';
|
||||
import { WrapperType } from '~/types/wrapper';
|
||||
import { DashboardCategory } from '../Wrappers/Category/Category';
|
||||
|
||||
import { DashboardSidebar } from '../Wrappers/Sidebar/Sidebar';
|
||||
import { DashboardWrapper } from '../Wrappers/Wrapper/Wrapper';
|
||||
import { useGridstackStore } from '../Wrappers/gridstack/store';
|
||||
|
||||
export const DashboardView = () => {
|
||||
const wrappers = useWrapperItems();
|
||||
export const BoardView = () => {
|
||||
const sections = useStackedSections();
|
||||
const sidebarsVisible = useSidebarVisibility();
|
||||
const { isReady, mainAreaRef } = usePrepareGridstack();
|
||||
|
||||
@@ -24,11 +21,11 @@ export const DashboardView = () => {
|
||||
|
||||
<Stack ref={mainAreaRef} mx={-10} style={{ flexGrow: 1 }}>
|
||||
{isReady &&
|
||||
wrappers.map((item) =>
|
||||
sections.map((item) =>
|
||||
item.type === 'category' ? (
|
||||
<DashboardCategory key={item.id} category={item as unknown as CategoryType} />
|
||||
<span>{item.name}</span>
|
||||
) : (
|
||||
<DashboardWrapper key={item.id} wrapper={item as WrapperType} />
|
||||
<DashboardWrapper key={item.id} section={item} />
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
@@ -39,6 +36,8 @@ export const DashboardView = () => {
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
// <DashboardCategory key={item.id} category={item as unknown as CategoryType} />
|
||||
// <DashboardWrapper key={item.id} wrapper={item as WrapperType} />
|
||||
|
||||
const usePrepareGridstack = () => {
|
||||
const mainAreaRef = useRef<HTMLDivElement>(null);
|
||||
@@ -58,29 +57,21 @@ const usePrepareGridstack = () => {
|
||||
};
|
||||
|
||||
const useSidebarVisibility = () => {
|
||||
const layoutSettings = useConfigContext()?.config?.settings.customization.layout;
|
||||
const board = useRequiredBoard();
|
||||
const screenLargerThanMd = useScreenLargerThan('md'); // For smaller screens mobile ribbons are displayed with drawers
|
||||
|
||||
const isScreenSizeUnknown = typeof screenLargerThanMd === 'undefined';
|
||||
|
||||
return {
|
||||
right: layoutSettings?.enabledRightSidebar && screenLargerThanMd,
|
||||
left: layoutSettings?.enabledLeftSidebar && screenLargerThanMd,
|
||||
right: board.isRightSidebarVisible && screenLargerThanMd,
|
||||
left: board.isLeftSidebarVisible && screenLargerThanMd,
|
||||
isLoading: isScreenSizeUnknown,
|
||||
};
|
||||
};
|
||||
|
||||
const useWrapperItems = () => {
|
||||
const { config } = useConfigContext();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
config
|
||||
? [
|
||||
...config.categories.map((c) => ({ ...c, type: 'category' })),
|
||||
...config.wrappers.map((w) => ({ ...w, type: 'wrapper' })),
|
||||
].sort((a, b) => a.position - b.position)
|
||||
: [],
|
||||
[config?.categories, config?.wrappers]
|
||||
const useStackedSections = () => {
|
||||
const board = useRequiredBoard();
|
||||
return board.sections.filter(
|
||||
(s): s is CategorySection | EmptySection => s.type === 'category' || s.type === 'empty'
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { DashboardView } from './DashboardView';
|
||||
import { BoardView } from './DashboardView';
|
||||
|
||||
export const DashboardDetailView = () => <DashboardView />;
|
||||
export const DashboardDetailView = () => <BoardView />;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { DashboardView } from './DashboardView';
|
||||
import { BoardView } from './DashboardView';
|
||||
|
||||
export const DashboardEditView = () => <DashboardView />;
|
||||
export const DashboardEditView = () => <BoardView />;
|
||||
|
||||
@@ -9,13 +9,11 @@ import {
|
||||
Title,
|
||||
createStyles,
|
||||
} from '@mantine/core';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { IconDotsVertical, IconShare3 } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { CategorySection } from '~/components/Board/context';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { CategoryType } from '~/types/category';
|
||||
import { useCardStyles } from '../../../layout/Common/useCardStyles';
|
||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||
import { WrapperContent } from '../WrapperContent';
|
||||
@@ -23,25 +21,24 @@ import { useGridstack } from '../gridstack/use-gridstack';
|
||||
import { CategoryEditMenu } from './CategoryEditMenu';
|
||||
|
||||
interface DashboardCategoryProps {
|
||||
category: CategoryType;
|
||||
section: CategorySection;
|
||||
}
|
||||
|
||||
export const DashboardCategory = ({ category }: DashboardCategoryProps) => {
|
||||
const { refs, apps, widgets } = useGridstack('category', category.id);
|
||||
export const DashboardCategory = ({ section }: DashboardCategoryProps) => {
|
||||
const { refs } = useGridstack({ section });
|
||||
const isEditMode = useEditModeStore((x) => x.enabled);
|
||||
const { config } = useConfigContext();
|
||||
const { classes: cardClasses, cx } = useCardStyles(true);
|
||||
const { classes } = useStyles();
|
||||
const { t } = useTranslation(['layout/common', 'common']);
|
||||
|
||||
const categoryList = config?.categories.map((x) => x.name) ?? [];
|
||||
const [toggledCategories, setToggledCategories] = useLocalStorage({
|
||||
//const categoryList = config?.categories.map((x) => x.name) ?? [];
|
||||
/*const [toggledCategories, setToggledCategories] = useLocalStorage({
|
||||
key: `${config?.configProperties.name}-app-shelf-toggled`,
|
||||
// This is a bit of a hack to toggle the categories on the first load, return a string[] of the categories
|
||||
defaultValue: categoryList,
|
||||
});
|
||||
});*/
|
||||
|
||||
const handleMenuClick = () => {
|
||||
/*const handleMenuClick = () => {
|
||||
for (let i = 0; i < apps.length; i += 1) {
|
||||
const app = apps[i];
|
||||
const popUp = window.open(app.url, app.id);
|
||||
@@ -79,6 +76,15 @@ export const DashboardCategory = ({ category }: DashboardCategoryProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
// value={isEditMode ? categoryList : toggledCategories}
|
||||
/*
|
||||
onChange={(state) => {
|
||||
// Cancel if edit mode is on
|
||||
if (isEditMode) return;
|
||||
setToggledCategories([...state]);
|
||||
}}
|
||||
*/
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
classNames={{
|
||||
@@ -87,19 +93,13 @@ export const DashboardCategory = ({ category }: DashboardCategoryProps) => {
|
||||
mx={10}
|
||||
chevronPosition="left"
|
||||
multiple
|
||||
value={isEditMode ? categoryList : toggledCategories}
|
||||
variant="separated"
|
||||
radius="lg"
|
||||
onChange={(state) => {
|
||||
// Cancel if edit mode is on
|
||||
if (isEditMode) return;
|
||||
setToggledCategories([...state]);
|
||||
}}
|
||||
>
|
||||
<Accordion.Item value={category.name}>
|
||||
<Accordion.Item value={section.name}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Accordion.Control icon={isEditMode && <CategoryEditMenu category={category} />}>
|
||||
<Title order={3}>{category.name}</Title>
|
||||
<Accordion.Control icon={isEditMode && <CategoryEditMenu category={section} />}>
|
||||
<Title order={3}>{section.name}</Title>
|
||||
</Accordion.Control>
|
||||
{!isEditMode && (
|
||||
<Menu withArrow withinPortal>
|
||||
@@ -109,7 +109,10 @@ export const DashboardCategory = ({ category }: DashboardCategoryProps) => {
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={handleMenuClick} icon={<IconShare3 size="1rem" />}>
|
||||
<Menu.Item
|
||||
onClick={/*handleMenuClick*/ undefined}
|
||||
icon={<IconShare3 size="1rem" />}
|
||||
>
|
||||
{t('actions.category.openAllInNewTab')}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
@@ -119,10 +122,10 @@ export const DashboardCategory = ({ category }: DashboardCategoryProps) => {
|
||||
<Accordion.Panel>
|
||||
<div
|
||||
className="grid-stack grid-stack-category"
|
||||
data-category={category.id}
|
||||
data-category={section.id}
|
||||
ref={refs.wrapper}
|
||||
>
|
||||
<WrapperContent apps={apps} refs={refs} widgets={widgets} />
|
||||
<WrapperContent items={section.items} refs={refs} />
|
||||
</div>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
import { WrapperType } from '~/types/wrapper';
|
||||
import { EmptySection } from '~/components/Board/context';
|
||||
|
||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||
import { WrapperContent } from '../WrapperContent';
|
||||
import { useGridstack } from '../gridstack/use-gridstack';
|
||||
|
||||
interface DashboardWrapperProps {
|
||||
wrapper: WrapperType;
|
||||
section: EmptySection;
|
||||
}
|
||||
|
||||
export const DashboardWrapper = ({ wrapper }: DashboardWrapperProps) => {
|
||||
const { refs, apps, widgets } = useGridstack('wrapper', wrapper.id);
|
||||
export const DashboardWrapper = ({ section }: DashboardWrapperProps) => {
|
||||
const { refs } = useGridstack({ section });
|
||||
const isEditMode = useEditModeStore((x) => x.enabled);
|
||||
const defaultClasses = 'grid-stack grid-stack-wrapper min-row';
|
||||
const defaultClasses = 'grid-stack grid-stack-empty min-row';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
apps.length > 0 || widgets.length > 0 || isEditMode
|
||||
section.items.length > 0 || isEditMode
|
||||
? defaultClasses
|
||||
: `${defaultClasses} gridstack-empty-wrapper`
|
||||
}
|
||||
style={{ transitionDuration: '0s' }}
|
||||
data-wrapper={wrapper.id}
|
||||
data-empty={section.id}
|
||||
ref={refs.wrapper}
|
||||
>
|
||||
<WrapperContent apps={apps} refs={refs} widgets={widgets} />
|
||||
<WrapperContent items={section.items} refs={refs} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { GridStack } from 'fily-publish-gridstack';
|
||||
import { MutableRefObject, RefObject } from 'react';
|
||||
|
||||
import { MutableRefObject, RefObject, useMemo } from 'react';
|
||||
import { AppItem, Item, WidgetItem } from '~/components/Board/context';
|
||||
import { AppType } from '~/types/app';
|
||||
import Widgets from '../../../widgets';
|
||||
import { WidgetWrapper } from '~/widgets/WidgetWrapper';
|
||||
import { IWidget, IWidgetDefinition } from '~/widgets/widgets';
|
||||
|
||||
import Widgets from '../../../widgets';
|
||||
import { appTileDefinition } from '../Tiles/Apps/AppTile';
|
||||
import { GridstackTileWrapper } from '../Tiles/TileWrapper';
|
||||
import { useGridstackStore } from './gridstack/store';
|
||||
|
||||
interface WrapperContentProps {
|
||||
apps: AppType[];
|
||||
widgets: IWidget<string, any>[];
|
||||
items: Item[];
|
||||
refs: {
|
||||
wrapper: RefObject<HTMLDivElement>;
|
||||
items: MutableRefObject<Record<string, RefObject<HTMLDivElement>>>;
|
||||
@@ -19,10 +19,9 @@ interface WrapperContentProps {
|
||||
};
|
||||
}
|
||||
|
||||
export function WrapperContent({ apps, refs, widgets }: WrapperContentProps) {
|
||||
const shapeSize = useGridstackStore((x) => x.currentShapeSize);
|
||||
|
||||
if (!shapeSize) return null;
|
||||
export function WrapperContent({ items, refs }: WrapperContentProps) {
|
||||
const apps = useMemo(() => items.filter((x): x is AppItem => x.type === 'app'), [items]);
|
||||
const widgets = useMemo(() => items.filter((x): x is WidgetItem => x.type === 'widget'), [items]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -35,17 +34,17 @@ export function WrapperContent({ apps, refs, widgets }: WrapperContentProps) {
|
||||
key={app.id}
|
||||
itemRef={refs.items.current[app.id]}
|
||||
{...tile}
|
||||
{...(app.shape[shapeSize]?.location ?? {})}
|
||||
{...(app.shape[shapeSize]?.size ?? {})}
|
||||
x={app.x}
|
||||
y={app.y}
|
||||
width={app.width}
|
||||
height={app.height}
|
||||
>
|
||||
<TileComponent className="grid-stack-item-content" app={app} />
|
||||
</GridstackTileWrapper>
|
||||
);
|
||||
})}
|
||||
{widgets.map((widget) => {
|
||||
const definition = Widgets[widget.type as keyof typeof Widgets] as
|
||||
| IWidgetDefinition
|
||||
| undefined;
|
||||
const definition = Widgets[widget.sort];
|
||||
if (!definition) return null;
|
||||
|
||||
return (
|
||||
@@ -55,14 +54,16 @@ export function WrapperContent({ apps, refs, widgets }: WrapperContentProps) {
|
||||
itemRef={refs.items.current[widget.id]}
|
||||
id={widget.id}
|
||||
{...definition.gridstack}
|
||||
{...widget.shape[shapeSize]?.location}
|
||||
{...widget.shape[shapeSize]?.size}
|
||||
x={widget.x}
|
||||
y={widget.y}
|
||||
width={widget.width}
|
||||
height={widget.height}
|
||||
>
|
||||
<WidgetWrapper
|
||||
className="grid-stack-item-content"
|
||||
widget={widget}
|
||||
widgetType={widget.type}
|
||||
WidgetComponent={definition.component}
|
||||
WidgetComponent={definition.component as any}
|
||||
/>
|
||||
</GridstackTileWrapper>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { GridItemHTMLElement, GridStack, GridStackNode } from 'fily-publish-gridstack';
|
||||
import { GridStack, GridStackNode } from 'fily-publish-gridstack';
|
||||
import { MutableRefObject, RefObject } from 'react';
|
||||
|
||||
import { AppType } from '~/types/app';
|
||||
import { ShapeType } from '~/types/shape';
|
||||
import { IWidget } from '~/widgets/widgets';
|
||||
import { Item } from '~/components/Board/context';
|
||||
|
||||
export const initializeGridstack = (
|
||||
areaType: 'wrapper' | 'category' | 'sidebar',
|
||||
@@ -11,8 +8,7 @@ export const initializeGridstack = (
|
||||
gridRef: MutableRefObject<GridStack | undefined>,
|
||||
itemRefs: MutableRefObject<Record<string, RefObject<HTMLDivElement>>>,
|
||||
areaId: string,
|
||||
items: AppType[],
|
||||
widgets: IWidget<string, any>[],
|
||||
items: Item[],
|
||||
isEditMode: boolean,
|
||||
wrapperColumnCount: number,
|
||||
shapeSize: 'sm' | 'md' | 'lg',
|
||||
@@ -45,6 +41,7 @@ export const initializeGridstack = (
|
||||
`.grid-stack-${areaType}[data-${areaType}='${areaId}']`
|
||||
);
|
||||
const grid = newGrid.current;
|
||||
if (!grid) return;
|
||||
// Must be used to update the column count after the initialization
|
||||
grid.column(columnCount, 'none');
|
||||
|
||||
@@ -66,39 +63,27 @@ export const initializeGridstack = (
|
||||
|
||||
grid.batchUpdate();
|
||||
grid.removeAll(false);
|
||||
items.forEach(({ id, shape }) => {
|
||||
const item = itemRefs.current[id]?.current;
|
||||
setAttributesFromShape(item, shape[shapeSize]);
|
||||
item && grid.makeWidget(item as HTMLDivElement);
|
||||
if (!shape[shapeSize] && item) {
|
||||
const gridItemElement = item as GridItemHTMLElement;
|
||||
items.forEach((item) => {
|
||||
const ref = itemRefs.current[item.id]?.current;
|
||||
setAttributesFromShape(ref, item);
|
||||
ref && grid.makeWidget(ref as HTMLDivElement);
|
||||
/*if (!item && ref) {
|
||||
const gridItemElement = ref as GridItemHTMLElement;
|
||||
if (gridItemElement.gridstackNode) {
|
||||
const { x, y, w, h } = gridItemElement.gridstackNode;
|
||||
tilesWithUnknownLocation.push({ x, y, w, h, type: 'app', id });
|
||||
tilesWithUnknownLocation.push({ x, y, w, h, type: 'app', id: item.id });
|
||||
}
|
||||
}
|
||||
});
|
||||
widgets.forEach(({ id, shape }) => {
|
||||
const item = itemRefs.current[id]?.current;
|
||||
setAttributesFromShape(item, shape[shapeSize]);
|
||||
item && grid.makeWidget(item as HTMLDivElement);
|
||||
if (!shape[shapeSize] && item) {
|
||||
const gridItemElement = item as GridItemHTMLElement;
|
||||
if (gridItemElement.gridstackNode) {
|
||||
const { x, y, w, h } = gridItemElement.gridstackNode;
|
||||
tilesWithUnknownLocation.push({ x, y, w, h, type: 'widget', id });
|
||||
}
|
||||
}
|
||||
}*/
|
||||
});
|
||||
grid.batchUpdate(false);
|
||||
};
|
||||
|
||||
function setAttributesFromShape(ref: HTMLDivElement | null, sizedShape: ShapeType['lg']) {
|
||||
if (!sizedShape || !ref) return;
|
||||
ref.setAttribute('gs-x', sizedShape.location.x.toString());
|
||||
ref.setAttribute('gs-y', sizedShape.location.y.toString());
|
||||
ref.setAttribute('gs-w', sizedShape.size.width.toString());
|
||||
ref.setAttribute('gs-h', sizedShape.size.height.toString());
|
||||
function setAttributesFromShape(ref: HTMLDivElement | null, item: Item) {
|
||||
if (!item || !ref) return;
|
||||
ref.setAttribute('gs-x', item.x.toString());
|
||||
ref.setAttribute('gs-y', item.y.toString());
|
||||
ref.setAttribute('gs-w', item.width.toString());
|
||||
ref.setAttribute('gs-h', item.height.toString());
|
||||
}
|
||||
|
||||
export type TileWithUnknownLocation = {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { GridstackBreakpoints } from '~/constants/gridstack-breakpoints';
|
||||
|
||||
@@ -31,7 +30,8 @@ export const useNamedWrapperColumnCount = (): 'small' | 'medium' | 'large' | nul
|
||||
};
|
||||
|
||||
export const useWrapperColumnCount = () => {
|
||||
const { config } = useConfigContext();
|
||||
return 10;
|
||||
/*const { config } = useConfigContext();
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
@@ -46,7 +46,7 @@ export const useWrapperColumnCount = () => {
|
||||
return config.settings.customization.gridstack?.columnCountSmall ?? 3;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}*/
|
||||
};
|
||||
|
||||
function getCurrentShapeSize(size: number) {
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { GridStack, GridStackNode } from 'fily-publish-gridstack';
|
||||
import { MutableRefObject, RefObject, createRef, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { Item, Section } from '~/components/Board/context';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
import { AppType } from '~/types/app';
|
||||
import { AreaType } from '~/types/area';
|
||||
import { IWidget } from '~/widgets/widgets';
|
||||
|
||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||
import { TileWithUnknownLocation, initializeGridstack } from './init-gridstack';
|
||||
import { useGridstackStore, useWrapperColumnCount } from './store';
|
||||
|
||||
interface UseGristackReturnType {
|
||||
apps: AppType[];
|
||||
widgets: IWidget<string, any>[];
|
||||
refs: {
|
||||
wrapper: RefObject<HTMLDivElement>;
|
||||
items: MutableRefObject<Record<string, RefObject<HTMLDivElement>>>;
|
||||
@@ -20,12 +19,12 @@ interface UseGristackReturnType {
|
||||
};
|
||||
}
|
||||
|
||||
export const useGridstack = (
|
||||
areaType: 'wrapper' | 'category' | 'sidebar',
|
||||
areaId: string
|
||||
): UseGristackReturnType => {
|
||||
type UseGridstackProps = {
|
||||
section: Section;
|
||||
};
|
||||
|
||||
export const useGridstack = ({ section }: UseGridstackProps): UseGristackReturnType => {
|
||||
const isEditMode = useEditModeStore((x) => x.enabled);
|
||||
const { config, configVersion, name: configName } = useConfigContext();
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
// define reference for wrapper - is used to calculate the width of the wrapper
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
@@ -43,40 +42,17 @@ export const useGridstack = (
|
||||
throw new Error('UseGridstack should not be executed before mainAreaWidth has been set!');
|
||||
}
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
config?.apps.filter(
|
||||
(x) =>
|
||||
x.area.type === areaType &&
|
||||
(x.area.type === 'sidebar'
|
||||
? x.area.properties.location === areaId
|
||||
: x.area.properties.id === areaId)
|
||||
) ?? [],
|
||||
[configVersion, config?.apps.length]
|
||||
);
|
||||
const widgets = useMemo(() => {
|
||||
if (!config) return [];
|
||||
return config.widgets.filter(
|
||||
(w) =>
|
||||
w.area.type === areaType &&
|
||||
(w.area.type === 'sidebar'
|
||||
? w.area.properties.location === areaId
|
||||
: w.area.properties.id === areaId)
|
||||
);
|
||||
}, [configVersion, config?.widgets.length]);
|
||||
const items = useMemo(() => section.items, [section.items.length]);
|
||||
|
||||
// define items in itemRefs for easy access and reference to items
|
||||
if (Object.keys(itemRefs.current).length !== items.length + (widgets ?? []).length) {
|
||||
if (Object.keys(itemRefs.current).length !== items.length) {
|
||||
items.forEach(({ id }: { id: keyof typeof itemRefs.current }) => {
|
||||
itemRefs.current[id] = itemRefs.current[id] || createRef();
|
||||
});
|
||||
(widgets ?? []).forEach(({ id }) => {
|
||||
itemRefs.current[id] = itemRefs.current[id] || createRef();
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (areaType === 'sidebar') return;
|
||||
if (section.type === 'sidebar') return;
|
||||
const widgetWidth = mainAreaWidth / wrapperColumnCount;
|
||||
// widget width is used to define sizes of gridstack items within global.scss
|
||||
root.style.setProperty('--gridstack-widget-width', widgetWidth.toString());
|
||||
@@ -88,6 +64,7 @@ export const useGridstack = (
|
||||
root.style.setProperty('--gridstack-column-count', wrapperColumnCount.toString());
|
||||
}, [wrapperColumnCount]);
|
||||
|
||||
const configName = 'default';
|
||||
const onChange = isEditMode
|
||||
? (changedNode: GridStackNode) => {
|
||||
if (!configName) return;
|
||||
@@ -154,18 +131,18 @@ export const useGridstack = (
|
||||
: previous.widgets.find((x) => x.id === itemId);
|
||||
if (!currentItem) return previous;
|
||||
|
||||
if (areaType === 'sidebar') {
|
||||
if (section.type === 'sidebar') {
|
||||
currentItem.area = {
|
||||
type: areaType,
|
||||
type: section.type,
|
||||
properties: {
|
||||
location: areaId as 'right' | 'left',
|
||||
location: section.position,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
currentItem.area = {
|
||||
type: areaType,
|
||||
type: section.type as any,
|
||||
properties: {
|
||||
id: areaId,
|
||||
id: section.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -241,13 +218,12 @@ export const useGridstack = (
|
||||
|
||||
const tilesWithUnknownLocation: TileWithUnknownLocation[] = [];
|
||||
initializeGridstack(
|
||||
areaType,
|
||||
section.type as any,
|
||||
wrapperRef,
|
||||
gridRef,
|
||||
itemRefs,
|
||||
areaId,
|
||||
section.id,
|
||||
items,
|
||||
widgets ?? [],
|
||||
isEditMode,
|
||||
wrapperColumnCount,
|
||||
shapeSize,
|
||||
@@ -308,11 +284,9 @@ export const useGridstack = (
|
||||
}),
|
||||
}));
|
||||
return removeEventHandlers;
|
||||
}, [items, wrapperRef.current, widgets, wrapperColumnCount]);
|
||||
}, [items, wrapperRef.current, wrapperColumnCount]);
|
||||
|
||||
return {
|
||||
apps: items,
|
||||
widgets: widgets ?? [],
|
||||
refs: {
|
||||
items: itemRefs,
|
||||
wrapper: wrapperRef,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Group, Image, Text } from '@mantine/core';
|
||||
import { Group, Image, Text, useMantineTheme } from '@mantine/core';
|
||||
import { useOptionalBoard } from '~/components/Board/context';
|
||||
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { usePrimaryGradient } from './useGradient';
|
||||
|
||||
interface LogoProps {
|
||||
@@ -10,29 +10,42 @@ interface LogoProps {
|
||||
}
|
||||
|
||||
export function Logo({ size = 'md', withoutText = false }: LogoProps) {
|
||||
const { config } = useConfigContext();
|
||||
const theme = useMantineTheme();
|
||||
const board = useOptionalBoard();
|
||||
const primaryGradient = usePrimaryGradient();
|
||||
const largerThanMd = useScreenLargerThan('md');
|
||||
|
||||
const colors = theme.fn.variant({
|
||||
variant: 'gradient',
|
||||
gradient: {
|
||||
from: 'red',
|
||||
to: 'orange',
|
||||
deg: 125,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Group spacing={size === 'md' ? 'xs' : 4} noWrap>
|
||||
<Image
|
||||
width={size === 'md' ? 50 : 12}
|
||||
src={config?.settings.customization.logoImageUrl || '/imgs/logo/logo-color.svg'}
|
||||
src={board?.logoImageUrl || '/imgs/logo/logo-color.svg'}
|
||||
alt="Homarr Logo"
|
||||
className="dashboard-header-logo-image"
|
||||
/>
|
||||
{withoutText || !largerThanMd ? null : (
|
||||
<Text
|
||||
size={size === 'md' ? 22 : 10}
|
||||
weight="bold"
|
||||
variant="gradient"
|
||||
className="dashboard-header-logo-text"
|
||||
gradient={primaryGradient}
|
||||
variant="gradient"
|
||||
weight="bold"
|
||||
className="dashboard-header-logo-text"
|
||||
>
|
||||
{config?.settings.customization.pageTitle || 'Homarr'}
|
||||
{board?.pageTitle || 'Homarr'}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Mantine gradient didn't work
|
||||
//
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { createStyles } from '@mantine/core';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useOptionalBoard } from '~/components/Board/context';
|
||||
|
||||
export const useCardStyles = (isCategory: boolean) => {
|
||||
const { config } = useConfigContext();
|
||||
const appOpacity = config?.settings.customization.appOpacity;
|
||||
const board = useOptionalBoard();
|
||||
const appOpacity = board?.appOpacity ?? 100;
|
||||
return createStyles(({ colorScheme }, _params) => {
|
||||
const opacity = (appOpacity || 100) / 100;
|
||||
const opacity = appOpacity / 100;
|
||||
|
||||
if (colorScheme === 'dark') {
|
||||
if (isCategory) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MantineGradient } from '@mantine/core';
|
||||
|
||||
import { useColorTheme } from '~/tools/color';
|
||||
|
||||
export const usePrimaryGradient = () => {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import Head from 'next/head';
|
||||
import React from 'react';
|
||||
import { useRequiredBoard } from '~/components/Board/context';
|
||||
import { firstUpperCase } from '~/tools/shared/strings';
|
||||
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
|
||||
export const BoardHeadOverride = () => {
|
||||
const { config, name } = useConfigContext();
|
||||
const board = useRequiredBoard();
|
||||
const { metaTitle, faviconImageUrl } = board;
|
||||
|
||||
if (!config || !name) return null;
|
||||
|
||||
const { metaTitle, faviconUrl } = config.settings.customization;
|
||||
const fallbackTitle = `${firstUpperCase(name)} Board • Homarr`;
|
||||
const fallbackTitle = `${firstUpperCase(board.name)} Board • Homarr`;
|
||||
const title = metaTitle && metaTitle.length > 0 ? metaTitle : fallbackTitle;
|
||||
|
||||
return (
|
||||
@@ -18,11 +14,11 @@ export const BoardHeadOverride = () => {
|
||||
<title>{title}</title>
|
||||
<meta name="apple-mobile-web-app-title" content={title} />
|
||||
|
||||
{faviconUrl && faviconUrl.length > 0 && (
|
||||
{faviconImageUrl && faviconImageUrl.length > 0 && (
|
||||
<>
|
||||
<link rel="shortcut icon" href={faviconUrl} />
|
||||
<link rel="shortcut icon" href={faviconImageUrl} />
|
||||
|
||||
<link rel="apple-touch-icon" href={faviconUrl} />
|
||||
<link rel="apple-touch-icon" href={faviconImageUrl} />
|
||||
</>
|
||||
)}
|
||||
</Head>
|
||||
|
||||
@@ -14,12 +14,11 @@ import { useSession } from 'next-auth/react';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useRequiredBoard } from '~/components/Board/context';
|
||||
import { useEditModeStore } from '~/components/Dashboard/Views/useEditModeStore';
|
||||
import { useNamedWrapperColumnCount } from '~/components/Dashboard/Wrappers/gridstack/store';
|
||||
import { BoardHeadOverride } from '~/components/layout/Meta/BoardHeadOverride';
|
||||
import { HeaderActionButton } from '~/components/layout/header/ActionButton';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
import { MainLayout } from './MainLayout';
|
||||
@@ -30,7 +29,7 @@ type BoardLayoutProps = {
|
||||
};
|
||||
|
||||
export const BoardLayout = ({ children, dockerEnabled }: BoardLayoutProps) => {
|
||||
const { config } = useConfigContext();
|
||||
const board = useRequiredBoard();
|
||||
const { data: session } = useSession();
|
||||
|
||||
return (
|
||||
@@ -41,7 +40,7 @@ export const BoardLayout = ({ children, dockerEnabled }: BoardLayoutProps) => {
|
||||
<BoardHeadOverride />
|
||||
<BackgroundImage />
|
||||
{children}
|
||||
<style>{clsx(config?.settings.customization.customCss)}</style>
|
||||
<style>{clsx(board.customCss)}</style>
|
||||
</MainLayout>
|
||||
);
|
||||
};
|
||||
@@ -77,7 +76,7 @@ const DockerButton = () => {
|
||||
};
|
||||
|
||||
const CustomizeBoardButton = () => {
|
||||
const { name } = useConfigContext();
|
||||
const { name } = useRequiredBoard();
|
||||
const { t } = useTranslation('boards/common');
|
||||
const href = useBoardLink(`/board/${name}/customize`);
|
||||
|
||||
@@ -95,7 +94,8 @@ const editModeNotificationId = 'toggle-edit-mode';
|
||||
|
||||
const ToggleEditModeButton = () => {
|
||||
const { enabled, toggleEditMode } = useEditModeStore();
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const board = useRequiredBoard();
|
||||
const { name } = board;
|
||||
const { mutateAsync: saveConfig } = api.config.save.useMutation();
|
||||
const namedWrapperColumnCount = useNamedWrapperColumnCount();
|
||||
const { t } = useTranslation(['layout/header/actions/toggle-edit-mode', 'common']);
|
||||
@@ -118,9 +118,9 @@ const ToggleEditModeButton = () => {
|
||||
|
||||
const save = async () => {
|
||||
toggleEditMode();
|
||||
if (!config || !configName) return;
|
||||
await saveConfig({ name: configName, config });
|
||||
Consola.log('Saved config to server', configName);
|
||||
if (!board || !name) return;
|
||||
await saveConfig({ name, config: {} as any });
|
||||
Consola.log('Saved config to server', name);
|
||||
hideNotification(editModeNotificationId);
|
||||
};
|
||||
|
||||
@@ -209,9 +209,9 @@ const AddElementButton = () => {
|
||||
};
|
||||
|
||||
const BackgroundImage = () => {
|
||||
const { config } = useConfigContext();
|
||||
const board = useRequiredBoard();
|
||||
|
||||
if (!config?.settings.customization.backgroundImageUrl) {
|
||||
if (!board.backgroundImageUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ const BackgroundImage = () => {
|
||||
styles={{
|
||||
body: {
|
||||
minHeight: '100vh',
|
||||
backgroundImage: `url('${config?.settings.customization.backgroundImageUrl}')`,
|
||||
backgroundImage: `url('${board.backgroundImageUrl}')`,
|
||||
backgroundPosition: 'center center',
|
||||
backgroundSize: 'cover',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useSession } from 'next-auth/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ReactNode, forwardRef, useMemo, useRef, useState } from 'react';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { AppItem, useOptionalBoard } from '~/components/Board/context';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
import { MovieModal } from './Search/MovieModal';
|
||||
@@ -31,19 +31,22 @@ export const Search = ({ isMobile, autoFocus }: SearchProps) => {
|
||||
const { data: userWithSettings } = api.user.withSettings.useQuery(undefined, {
|
||||
enabled: !!sessionData?.user,
|
||||
});
|
||||
const { config } = useConfigContext();
|
||||
const board = useOptionalBoard();
|
||||
const { colors } = useMantineTheme();
|
||||
const router = useRouter();
|
||||
const [showMovieModal, movieModal] = useDisclosure(router.query.movie === 'true');
|
||||
|
||||
const apps = useConfigApps(search);
|
||||
const apps = useBoardApps(search);
|
||||
const engines = generateEngines(
|
||||
search,
|
||||
userWithSettings?.settings.searchTemplate ?? 'https://www.google.com/search?q=%s'
|
||||
)
|
||||
.filter(
|
||||
(engine) =>
|
||||
engine.sort !== 'movie' || config?.apps.some((app) => app.integration.type === engine.value)
|
||||
engine.sort !== 'movie' ||
|
||||
board?.sections.some((section) =>
|
||||
section.items.some((x) => x.type === 'app' && x.integration?.type === engine.value)
|
||||
)
|
||||
)
|
||||
.map((engine) => ({
|
||||
...engine,
|
||||
@@ -139,25 +142,27 @@ const getItemComponent = (icon: SearchAutoCompleteItem['icon']) => {
|
||||
);
|
||||
};
|
||||
|
||||
const useConfigApps = (search: string) => {
|
||||
const { config } = useConfigContext();
|
||||
const useBoardApps = (search: string) => {
|
||||
const board = useOptionalBoard();
|
||||
return useMemo(() => {
|
||||
if (search.trim().length === 0) return [];
|
||||
const apps = config?.apps.filter((app) =>
|
||||
app.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
return (
|
||||
apps?.map((app) => ({
|
||||
icon: app.appearance.iconUrl,
|
||||
label: app.name,
|
||||
value: app.name,
|
||||
sort: 'app',
|
||||
metaData: {
|
||||
url: app.behaviour.externalUrl,
|
||||
},
|
||||
})) ?? []
|
||||
);
|
||||
}, [search, config]);
|
||||
if (!board) return [];
|
||||
const apps = board.sections
|
||||
.flatMap((section) => section.items.filter((item) => item.type === 'app'))
|
||||
.filter(
|
||||
(x): x is AppItem => x.type === 'app' && x.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return apps.map((app) => ({
|
||||
icon: app.iconUrl,
|
||||
label: app.name,
|
||||
value: app.name,
|
||||
sort: 'app',
|
||||
metaData: {
|
||||
url: app.externalUrl,
|
||||
},
|
||||
}));
|
||||
}, [search, board]);
|
||||
};
|
||||
|
||||
type SearchAutoCompleteItem = {
|
||||
|
||||
@@ -16,24 +16,24 @@ import { AppProps } from 'next/app';
|
||||
import { useEffect, useState } from 'react';
|
||||
import 'video.js/dist/video-js.css';
|
||||
import { CommonHead } from '~/components/layout/Meta/CommonHead';
|
||||
import { ConfigProvider } from '~/config/provider';
|
||||
import { env } from '~/env.js';
|
||||
import { ColorSchemeProvider } from '~/hooks/use-colorscheme';
|
||||
import { modals } from '~/modals';
|
||||
import { ColorTheme } from '~/tools/color';
|
||||
import { getLanguageByCode } from '~/tools/language';
|
||||
import {
|
||||
ServerSidePackageAttributesType,
|
||||
getServiceSidePackageAttributes,
|
||||
} from '~/tools/server/getPackageVersion';
|
||||
import { theme } from '~/tools/server/theme/theme';
|
||||
import { ConfigType } from '~/types/config';
|
||||
import { api } from '~/utils/api';
|
||||
import { colorSchemeParser } from '~/validations/user';
|
||||
|
||||
import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../data/constants';
|
||||
import nextI18nextConfig from '../../next-i18next.config.js';
|
||||
import { ConfigProvider } from '~/config/provider';
|
||||
import '../styles/global.scss';
|
||||
import { ColorTheme } from '~/tools/color';
|
||||
import {
|
||||
ServerSidePackageAttributesType,
|
||||
getServiceSidePackageAttributes,
|
||||
} from '~/tools/server/getPackageVersion';
|
||||
import { theme } from '~/tools/server/theme/theme';
|
||||
|
||||
dayjs.extend(locale);
|
||||
dayjs.extend(utc);
|
||||
@@ -69,6 +69,7 @@ function App(
|
||||
const [primaryShade, setPrimaryShade] = useState<MantineTheme['primaryShade']>(
|
||||
props.pageProps.primaryShade ?? 6
|
||||
);
|
||||
|
||||
const colorTheme = {
|
||||
primaryColor,
|
||||
secondaryColor,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
||||
import { SSRConfig } from 'next-i18next';
|
||||
import { z } from 'zod';
|
||||
import { Dashboard } from '~/components/Dashboard/Dashboard';
|
||||
import { Board } from '~/components/Dashboard/Dashboard';
|
||||
import { BoardLayout } from '~/components/layout/Templates/BoardLayout';
|
||||
import { useInitConfig } from '~/config/init';
|
||||
import { env } from '~/env';
|
||||
@@ -21,7 +21,7 @@ export default function BoardPage({
|
||||
|
||||
return (
|
||||
<BoardLayout dockerEnabled={dockerEnabled}>
|
||||
<Dashboard />
|
||||
<Board />
|
||||
</BoardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
||||
import { SSRConfig } from 'next-i18next';
|
||||
import { createContext, useContext } from 'react';
|
||||
import { Dashboard } from '~/components/Dashboard/Dashboard';
|
||||
import { BoardProvider } from '~/components/Board/context';
|
||||
import { Board } from '~/components/Dashboard/Dashboard';
|
||||
import { BoardLayout } from '~/components/layout/Templates/BoardLayout';
|
||||
import { env } from '~/env';
|
||||
import { createTrpcServersideHelpers } from '~/server/api/helper';
|
||||
@@ -9,62 +10,23 @@ import { getServerAuthSession } from '~/server/auth';
|
||||
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { boardNamespaces } from '~/tools/server/translation-namespaces';
|
||||
import { RouterOutputs, api } from '~/utils/api';
|
||||
|
||||
type BoardContextType = {
|
||||
boardName: string;
|
||||
layout?: string;
|
||||
board: RouterOutputs['boards']['byName'];
|
||||
};
|
||||
const BoardContext = createContext<BoardContextType>(null!);
|
||||
type BoardProviderProps = {
|
||||
boardName: string;
|
||||
layout?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
const BoardProvider = ({ children, ...props }: BoardProviderProps) => {
|
||||
const { data: board } = api.boards.byName.useQuery(props);
|
||||
|
||||
return (
|
||||
<BoardContext.Provider
|
||||
value={{
|
||||
...props,
|
||||
board: board!,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BoardContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useBoard = () => {
|
||||
const ctx = useContext(BoardContext);
|
||||
if (!ctx) throw new Error('useBoard must be used within a BoardProvider');
|
||||
return ctx.board;
|
||||
};
|
||||
|
||||
const Data = () => {
|
||||
const board = useBoard();
|
||||
|
||||
return <pre>{JSON.stringify(board, null, 2)}</pre>;
|
||||
};
|
||||
import { RouterOutputs } from '~/utils/api';
|
||||
|
||||
export default function BoardPage({
|
||||
boardName,
|
||||
board,
|
||||
dockerEnabled,
|
||||
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
return (
|
||||
<BoardProvider boardName={boardName}>
|
||||
<BoardProvider initialBoard={board}>
|
||||
<BoardLayout dockerEnabled={dockerEnabled}>
|
||||
<Data />
|
||||
<Dashboard />
|
||||
<Board />
|
||||
</BoardLayout>
|
||||
</BoardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type BoardGetServerSideProps = {
|
||||
boardName: string;
|
||||
board: RouterOutputs['boards']['byName'];
|
||||
dockerEnabled: boolean;
|
||||
_nextI18Next?: SSRConfig['_nextI18Next'];
|
||||
};
|
||||
@@ -81,8 +43,18 @@ export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = a
|
||||
);
|
||||
|
||||
const helpers = await createTrpcServersideHelpers(ctx);
|
||||
await helpers.boards.byName.prefetch({ boardName });
|
||||
const board = await helpers.boards.byNameSimple.fetch({ boardName });
|
||||
const board = await helpers.boards.byName.fetch({ boardName }).catch((err) => {
|
||||
if (err instanceof TRPCError && err.code === 'NOT_FOUND') {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (!board) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!board.allowGuests && !session?.user) {
|
||||
return {
|
||||
@@ -92,7 +64,7 @@ export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = a
|
||||
|
||||
return {
|
||||
props: {
|
||||
boardName,
|
||||
board,
|
||||
primaryColor: board.primaryColor,
|
||||
secondaryColor: board.secondaryColor,
|
||||
primaryShade: board.primaryShade,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Image,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Image,
|
||||
UnstyledButton,
|
||||
createStyles,
|
||||
} from '@mantine/core';
|
||||
@@ -16,11 +18,13 @@ import { useSession } from 'next-auth/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
|
||||
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { OnlyKeysWithStructure } from '~/types/helpers';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
import { type quickActions } from '../../../public/locales/en/manage/index.json';
|
||||
|
||||
@@ -64,6 +68,8 @@ const ManagementPage = () => {
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<AddBoardCard />
|
||||
|
||||
<Text weight="bold" mb="md">
|
||||
{t('quickActions.title')}
|
||||
</Text>
|
||||
@@ -156,3 +162,34 @@ const useStyles = createStyles((theme) => ({
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const AddBoardCard = () => {
|
||||
const { mutate } = api.boards.exampleBoard.useMutation();
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const handleClick = () => {
|
||||
if (!value) return;
|
||||
mutate(
|
||||
{ boardName: value },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setValue('');
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder w="100%">
|
||||
<Group w="100%" noWrap>
|
||||
<TextInput
|
||||
w="100%"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
placeholder="Boardname"
|
||||
/>
|
||||
<Button onClick={handleClick}>Create test board</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import fs from 'fs';
|
||||
import { z } from 'zod';
|
||||
import { db } from '~/server/db';
|
||||
import { WidgetType } from '~/server/db/items';
|
||||
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
|
||||
import { boards, layoutItems, layouts, sections } from '~/server/db/schema';
|
||||
import {
|
||||
appItems,
|
||||
apps,
|
||||
boards,
|
||||
items,
|
||||
layoutItems,
|
||||
layouts,
|
||||
sections,
|
||||
widgets,
|
||||
} from '~/server/db/schema';
|
||||
import { configExists } from '~/tools/config/configExists';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
|
||||
@@ -142,14 +153,209 @@ export const boardRouter = createTRPCRouter({
|
||||
message: 'Board not found',
|
||||
});
|
||||
}
|
||||
|
||||
console.log(board);
|
||||
return board;
|
||||
}),
|
||||
exampleBoard: protectedProcedure
|
||||
.input(z.object({ boardName: configNameSchema }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const boardId = randomUUID();
|
||||
const layoutId = randomUUID();
|
||||
const sectionId = randomUUID();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: input.boardName,
|
||||
ownerId: ctx.session.user.id,
|
||||
});
|
||||
|
||||
await db.insert(layouts).values({
|
||||
id: layoutId,
|
||||
name: 'default',
|
||||
boardId,
|
||||
});
|
||||
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
layoutId,
|
||||
type: 'empty',
|
||||
position: 0,
|
||||
});
|
||||
|
||||
await addApp({
|
||||
boardId,
|
||||
sectionId,
|
||||
name: 'Contribute',
|
||||
internalUrl: 'https://github.com/ajnart/homarr',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/github.png',
|
||||
width: 2,
|
||||
height: 1,
|
||||
x: 2,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
await addApp({
|
||||
boardId,
|
||||
sectionId,
|
||||
name: 'Discord',
|
||||
internalUrl: 'https://discord.com/invite/aCsmEV5RgA',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/discord.png',
|
||||
width: 2,
|
||||
height: 1,
|
||||
x: 4,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
await addApp({
|
||||
boardId,
|
||||
sectionId,
|
||||
name: 'Donate',
|
||||
internalUrl: 'https://ko-fi.com/ajnart',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/ko-fi.png',
|
||||
width: 2,
|
||||
height: 1,
|
||||
x: 6,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
await addApp({
|
||||
boardId,
|
||||
sectionId,
|
||||
name: 'Documentation',
|
||||
internalUrl: 'https://homarr.dev',
|
||||
iconUrl: '/imgs/logo/logo.png',
|
||||
width: 2,
|
||||
height: 2,
|
||||
x: 6,
|
||||
y: 1,
|
||||
});
|
||||
|
||||
await addWidget({
|
||||
boardId,
|
||||
sectionId,
|
||||
type: 'weather',
|
||||
width: 2,
|
||||
height: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
await addWidget({
|
||||
boardId,
|
||||
sectionId,
|
||||
type: 'date',
|
||||
width: 2,
|
||||
height: 1,
|
||||
x: 8,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
await addWidget({
|
||||
boardId,
|
||||
sectionId,
|
||||
type: 'date',
|
||||
width: 2,
|
||||
height: 1,
|
||||
x: 8,
|
||||
y: 1,
|
||||
});
|
||||
|
||||
await addWidget({
|
||||
boardId,
|
||||
sectionId,
|
||||
type: 'notebook',
|
||||
width: 6,
|
||||
height: 3,
|
||||
x: 0,
|
||||
y: 1,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
type AddWidgetProps = {
|
||||
boardId: string;
|
||||
sectionId: string;
|
||||
type: WidgetType;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const addWidget = async ({ boardId, sectionId, type, ...positionProps }: AddWidgetProps) => {
|
||||
const itemId = randomUUID();
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
type: 'widget',
|
||||
boardId,
|
||||
});
|
||||
|
||||
const widgetId = randomUUID();
|
||||
await db.insert(widgets).values({
|
||||
id: widgetId,
|
||||
type,
|
||||
itemId,
|
||||
});
|
||||
|
||||
const layoutItemId = randomUUID();
|
||||
await db.insert(layoutItems).values({
|
||||
id: layoutItemId,
|
||||
itemId,
|
||||
sectionId,
|
||||
...positionProps,
|
||||
});
|
||||
};
|
||||
|
||||
type AddAppProps = {
|
||||
boardId: string;
|
||||
sectionId: string;
|
||||
name: string;
|
||||
internalUrl: string;
|
||||
iconUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const addApp = async ({
|
||||
boardId,
|
||||
sectionId,
|
||||
iconUrl,
|
||||
internalUrl,
|
||||
name,
|
||||
...positionProps
|
||||
}: AddAppProps) => {
|
||||
const itemId = randomUUID();
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
type: 'app',
|
||||
boardId,
|
||||
});
|
||||
|
||||
const appId = randomUUID();
|
||||
await db.insert(apps).values({
|
||||
id: appId,
|
||||
name,
|
||||
internalUrl,
|
||||
iconUrl,
|
||||
});
|
||||
|
||||
await db.insert(appItems).values({
|
||||
appId: appId,
|
||||
itemId: itemId,
|
||||
});
|
||||
|
||||
const layoutItemId = randomUUID();
|
||||
await db.insert(layoutItems).values({
|
||||
id: layoutItemId,
|
||||
itemId,
|
||||
sectionId,
|
||||
...positionProps,
|
||||
});
|
||||
};
|
||||
|
||||
const getAppsForSectionsAsync = async (sectionIds: string[]) => {
|
||||
if (sectionIds.length === 0) return [];
|
||||
|
||||
return await db.query.appItems.findMany({
|
||||
with: {
|
||||
app: {
|
||||
@@ -237,7 +443,7 @@ const mapSection = (
|
||||
items: (ReturnType<typeof mapWidget> | ReturnType<typeof mapApp>)[]
|
||||
) => {
|
||||
const { layoutId, ...withoutLayoutId } = section;
|
||||
if (section.type === 'empty') {
|
||||
if (withoutLayoutId.type === 'empty') {
|
||||
const { name, position, type, ...sectionProps } = withoutLayoutId;
|
||||
return {
|
||||
...sectionProps,
|
||||
@@ -246,7 +452,7 @@ const mapSection = (
|
||||
items,
|
||||
};
|
||||
}
|
||||
if (section.type === 'hidden') {
|
||||
if (withoutLayoutId.type === 'hidden') {
|
||||
const { name, position, type, ...sectionProps } = withoutLayoutId;
|
||||
return {
|
||||
...sectionProps,
|
||||
@@ -255,7 +461,7 @@ const mapSection = (
|
||||
items,
|
||||
};
|
||||
}
|
||||
if (section.type === 'category') {
|
||||
if (withoutLayoutId.type === 'category') {
|
||||
const { name, position, type, ...sectionProps } = withoutLayoutId;
|
||||
return {
|
||||
...sectionProps,
|
||||
|
||||
@@ -130,7 +130,7 @@ export const boards = sqliteTable('board', {
|
||||
backgroundImageUrl: text('background_image_url'),
|
||||
primaryColor: text('primary_color'),
|
||||
secondaryColor: text('secondary_color'),
|
||||
primaryShade: text('primary_shade'),
|
||||
primaryShade: int('primary_shade'),
|
||||
appOpacity: int('app_opacity'),
|
||||
customCss: text('custom_css'),
|
||||
|
||||
@@ -165,7 +165,9 @@ export const integrationSecrets = sqliteTable(
|
||||
export const widgets = sqliteTable('widget', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
type: text('type').$type<WidgetType>().notNull(),
|
||||
itemId: text('item_id').notNull(),
|
||||
itemId: text('item_id')
|
||||
.notNull()
|
||||
.references(() => items.id, { onDelete: 'cascade' }),
|
||||
});
|
||||
|
||||
export const widgetOptions = sqliteTable(
|
||||
@@ -205,7 +207,7 @@ export const appItems = sqliteTable('app_item', {
|
||||
openInNewTab: int('open_in_new_tab', { mode: 'boolean' }).notNull().default(false),
|
||||
isPingEnabled: int('is_ping_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||
fontSize: int('font_size').notNull().default(16),
|
||||
namePosition: text('name_position').$type<AppNamePosition>().notNull().default('right'),
|
||||
namePosition: text('name_position').$type<AppNamePosition>().notNull().default('top'),
|
||||
nameStyle: text('name_style').$type<AppNameStyle>().notNull().default('show'),
|
||||
nameLineClamp: int('name_line_clamp').notNull().default(1),
|
||||
appId: text('app_id')
|
||||
@@ -238,8 +240,12 @@ export const appStatusCodes = sqliteTable(
|
||||
|
||||
export const layoutItems = sqliteTable('layout_item', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
sectionId: text('section_id').notNull(),
|
||||
itemId: text('item_id').notNull(),
|
||||
sectionId: text('section_id')
|
||||
.notNull()
|
||||
.references(() => sections.id, { onDelete: 'cascade' }),
|
||||
itemId: text('item_id')
|
||||
.notNull()
|
||||
.references(() => items.id, { onDelete: 'cascade' }),
|
||||
x: int('x').notNull(),
|
||||
y: int('y').notNull(),
|
||||
width: int('width').notNull(),
|
||||
|
||||
@@ -10,10 +10,9 @@ import { ParsedUrlQuery } from 'querystring';
|
||||
export const checkForSessionOrAskForLogin = (
|
||||
context: GetServerSidePropsContext<ParsedUrlQuery, PreviewData>,
|
||||
session: Session | null,
|
||||
accessCallback: () => boolean,
|
||||
accessCallback: () => boolean
|
||||
): GetServerSidePropsResult<any> | undefined => {
|
||||
if (!session?.user) {
|
||||
console.log('detected logged out user!');
|
||||
return {
|
||||
props: {},
|
||||
redirect: {
|
||||
@@ -26,8 +25,8 @@ export const checkForSessionOrAskForLogin = (
|
||||
if (!accessCallback()) {
|
||||
return {
|
||||
props: {},
|
||||
notFound: true
|
||||
}
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { ComponentType } from 'react';
|
||||
|
||||
import Widgets from '.';
|
||||
import { WidgetItem } from '~/components/Board/context';
|
||||
import { HomarrCardWrapper } from '~/components/Dashboard/Tiles/HomarrCardWrapper';
|
||||
import { WidgetsMenu } from '~/components/Dashboard/Tiles/Widgets/WidgetsMenu';
|
||||
|
||||
import Widgets from '.';
|
||||
import ErrorBoundary from './boundary';
|
||||
import { IWidget } from './widgets';
|
||||
|
||||
interface WidgetWrapperProps {
|
||||
widgetType: string;
|
||||
widget: IWidget<string, any>;
|
||||
widget: WidgetItem;
|
||||
className: string;
|
||||
WidgetComponent: ComponentType<{ widget: IWidget<string, any> }>;
|
||||
WidgetComponent: ComponentType<{ widget: WidgetItem }>;
|
||||
}
|
||||
|
||||
// If a property has no value, set it to the default value
|
||||
const useWidget = <T extends IWidget<string, any>>(widget: T): T => {
|
||||
const definition = Widgets[widget.type as keyof typeof Widgets];
|
||||
const useWidget = <T extends WidgetItem>(widget: T): T => {
|
||||
const definition = Widgets[widget.sort];
|
||||
|
||||
const newProps = { ...widget.properties };
|
||||
const newProps = { ...widget.options };
|
||||
|
||||
Object.entries(definition.options).forEach(([key, option]) => {
|
||||
if (newProps[key] == null) {
|
||||
@@ -27,7 +28,7 @@ const useWidget = <T extends IWidget<string, any>>(widget: T): T => {
|
||||
|
||||
return {
|
||||
...widget,
|
||||
properties: newProps,
|
||||
options: newProps,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ import timezones from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSetSafeInterval } from '~/hooks/useSetSafeInterval';
|
||||
import { getLanguageByCode } from '~/tools/language';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
import { useSetSafeInterval } from '~/hooks/useSetSafeInterval';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
import { IWidget, InferWidget } from '../widgets';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezones);
|
||||
@@ -67,7 +67,7 @@ const definition = defineWidget({
|
||||
component: DateTile,
|
||||
});
|
||||
|
||||
export type IDateWidget = IWidget<(typeof definition)['id'], typeof definition>;
|
||||
export type IDateWidget = InferWidget<typeof definition>;
|
||||
|
||||
interface DateTileProps {
|
||||
widget: IDateWidget;
|
||||
@@ -75,41 +75,41 @@ interface DateTileProps {
|
||||
|
||||
function DateTile({ widget }: DateTileProps) {
|
||||
const date = useDateState(
|
||||
widget.properties.enableTimezone ? widget.properties.timezoneLocation : undefined
|
||||
widget.options.enableTimezone ? widget.options.timezoneLocation : undefined
|
||||
);
|
||||
const formatString = widget.properties.display24HourFormat ? 'HH:mm' : 'h:mm A';
|
||||
const formatString = widget.options.display24HourFormat ? 'HH:mm' : 'h:mm A';
|
||||
const { ref, width } = useElementSize();
|
||||
const { cx, classes } = useStyles();
|
||||
|
||||
return (
|
||||
<Stack ref={ref} className={cx(classes.wrapper, 'dashboard-tile-clock-wrapper')}>
|
||||
{widget.properties.enableTimezone && widget.properties.titleState !== 'none' && (
|
||||
{widget.options.enableTimezone && widget.options.titleState !== 'none' && (
|
||||
<Text
|
||||
size={width < 150 ? 'sm' : 'lg'}
|
||||
className={cx(classes.extras, 'dashboard-tile-clock-city')}
|
||||
>
|
||||
{widget.properties.timezoneLocation.name}
|
||||
{widget.properties.titleState === 'both' && dayjs(date).format(' (z)')}
|
||||
{widget.options.timezoneLocation.name}
|
||||
{widget.options.titleState === 'both' && dayjs(date).format(' (z)')}
|
||||
</Text>
|
||||
)}
|
||||
<Text className={cx(classes.clock, 'dashboard-tile-clock-hour')}>
|
||||
{dayjs(date).format(formatString)}
|
||||
</Text>
|
||||
{!widget.properties.dateFormat.includes('hide') && (
|
||||
{!widget.options.dateFormat.includes('hide') && (
|
||||
<Text
|
||||
size={width < 150 ? 'sm' : 'lg'}
|
||||
pt="0.2rem"
|
||||
className={cx(classes.extras, 'dashboard-tile-clock-date')}
|
||||
>
|
||||
{dayjs(date).format(widget.properties.dateFormat)}
|
||||
{dayjs(date).format(widget.options.dateFormat)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createStyles(()=>({
|
||||
wrapper:{
|
||||
const useStyles = createStyles(() => ({
|
||||
wrapper: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-evenly',
|
||||
@@ -117,17 +117,17 @@ const useStyles = createStyles(()=>({
|
||||
height: '100%',
|
||||
gap: 0,
|
||||
},
|
||||
clock:{
|
||||
clock: {
|
||||
lineHeight: '1',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 700,
|
||||
fontSize: '2.125rem',
|
||||
},
|
||||
extras:{
|
||||
extras: {
|
||||
lineHeight: '1',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
}))
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* State which updates when the minute is changing
|
||||
@@ -142,7 +142,7 @@ const useDateState = (location?: { latitude: number; longitude: number }) => {
|
||||
const { data: userWithSettings } = api.user.withSettings.useQuery(undefined, {
|
||||
enabled: !!sessionData?.user,
|
||||
});
|
||||
const userLanguage = userWithSettings?.settings.language;
|
||||
const userLanguage = userWithSettings?.settings.language;
|
||||
const [date, setDate] = useState(getNewDate(timezone));
|
||||
const setSafeInterval = useSetSafeInterval();
|
||||
const timeoutRef = useRef<NodeJS.Timeout>(); // reference for initial timeout until first minute change
|
||||
|
||||
@@ -5,12 +5,12 @@ import { IconEdit, IconEditOff } from '@tabler/icons-react';
|
||||
import { BubbleMenu, useEditor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { useState } from 'react';
|
||||
import { useRequiredBoard } from '~/components/Board/context';
|
||||
import { useEditModeStore } from '~/components/Dashboard/Views/useEditModeStore';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
import { useColorTheme } from '~/tools/color';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
import { useEditModeStore } from '~/components/Dashboard/Views/useEditModeStore';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { WidgetLoading } from '../loading';
|
||||
import { INotebookWidget } from './NotebookWidgetTile';
|
||||
|
||||
@@ -19,12 +19,12 @@ Link.configure({
|
||||
});
|
||||
|
||||
export function Editor({ widget }: { widget: INotebookWidget }) {
|
||||
const [content, setContent] = useState(widget.properties.content);
|
||||
const [content, setContent] = useState(widget.options.content);
|
||||
|
||||
const { enabled } = useEditModeStore();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const board = useRequiredBoard();
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
const { primaryColor } = useColorTheme();
|
||||
|
||||
@@ -47,7 +47,7 @@ export function Editor({ widget }: { widget: INotebookWidget }) {
|
||||
editor.setEditable(current);
|
||||
|
||||
updateConfig(
|
||||
configName!,
|
||||
board.name!,
|
||||
(previous) => {
|
||||
const currentWidget = previous.widgets.find((x) => x.id === widget.id);
|
||||
currentWidget!.properties.content = debouncedContent;
|
||||
@@ -64,7 +64,7 @@ export function Editor({ widget }: { widget: INotebookWidget }) {
|
||||
);
|
||||
|
||||
void mutateAsync({
|
||||
configName: configName!,
|
||||
configName: board.name!,
|
||||
content: debouncedContent,
|
||||
widgetId: widget.id,
|
||||
});
|
||||
@@ -72,7 +72,7 @@ export function Editor({ widget }: { widget: INotebookWidget }) {
|
||||
return current;
|
||||
};
|
||||
|
||||
if (!config || !configName) return <WidgetLoading />;
|
||||
if (!board) return <WidgetLoading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -104,7 +104,7 @@ export function Editor({ widget }: { widget: INotebookWidget }) {
|
||||
>
|
||||
<RichTextEditor.Toolbar
|
||||
style={{
|
||||
display: isEditing && widget.properties.showToolbar === true ? 'flex' : 'none',
|
||||
display: isEditing && widget.options.showToolbar === true ? 'flex' : 'none',
|
||||
}}
|
||||
>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { IconNotes } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
import { InferWidget } from '../widgets';
|
||||
|
||||
const Editor = dynamic(() => import('./NotebookEditor').then((module) => module.Editor), {
|
||||
ssr: false,
|
||||
@@ -34,7 +33,7 @@ const definition = defineWidget({
|
||||
|
||||
export default definition;
|
||||
|
||||
export type INotebookWidget = IWidget<(typeof definition)['id'], typeof definition>;
|
||||
export type INotebookWidget = InferWidget<typeof definition>;
|
||||
|
||||
interface NotebookWidgetProps {
|
||||
widget: INotebookWidget;
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
IconCloudRain,
|
||||
IconMapPin,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
import { IWidget, InferWidget } from '../widgets';
|
||||
import { WeatherIcon } from './WeatherIcon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'weather',
|
||||
@@ -43,14 +43,14 @@ const definition = defineWidget({
|
||||
component: WeatherTile,
|
||||
});
|
||||
|
||||
export type IWeatherWidget = IWidget<(typeof definition)['id'], typeof definition>;
|
||||
export type IWeatherWidget = InferWidget<typeof definition>;
|
||||
|
||||
interface WeatherTileProps {
|
||||
widget: IWeatherWidget;
|
||||
}
|
||||
|
||||
function WeatherTile({ widget }: WeatherTileProps) {
|
||||
const { data: weather, isLoading, isError } = api.weather.at.useQuery(widget.properties.location);
|
||||
const { data: weather, isLoading, isError } = api.weather.at.useQuery(widget.options.location);
|
||||
const { width, ref } = useElementSize();
|
||||
const { t } = useTranslation('modules/weather');
|
||||
|
||||
@@ -100,32 +100,23 @@ function WeatherTile({ widget }: WeatherTileProps) {
|
||||
>
|
||||
<WeatherIcon size={width < 300 ? 30 : 50} code={weather.current_weather.weathercode} />
|
||||
<Title size={'h2'}>
|
||||
{getPerferedUnit(
|
||||
weather.current_weather.temperature,
|
||||
widget.properties.displayInFahrenheit
|
||||
)}
|
||||
{getPerferedUnit(weather.current_weather.temperature, widget.options.displayInFahrenheit)}
|
||||
</Title>
|
||||
</Flex>
|
||||
|
||||
{width > 200 && (
|
||||
<Group noWrap spacing="xs">
|
||||
<IconArrowUpRight />
|
||||
{getPerferedUnit(
|
||||
weather.daily.temperature_2m_max[0],
|
||||
widget.properties.displayInFahrenheit
|
||||
)}
|
||||
{getPerferedUnit(weather.daily.temperature_2m_max[0], widget.options.displayInFahrenheit)}
|
||||
<IconArrowDownRight />
|
||||
{getPerferedUnit(
|
||||
weather.daily.temperature_2m_min[0],
|
||||
widget.properties.displayInFahrenheit
|
||||
)}
|
||||
{getPerferedUnit(weather.daily.temperature_2m_min[0], widget.options.displayInFahrenheit)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{widget.properties.displayCityName && (
|
||||
{widget.options.displayCityName && (
|
||||
<Group noWrap spacing={5} align="center">
|
||||
<IconMapPin height={15} width={15} />
|
||||
<Text style={{ whiteSpace: 'nowrap' }}>{widget.properties.location.name}</Text>
|
||||
<Text style={{ whiteSpace: 'nowrap' }}>{widget.options.location.name}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { Icon } from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
|
||||
import { WidgetItem } from '~/components/Board/context';
|
||||
import { AreaType } from '~/types/area';
|
||||
import { ShapeType } from '~/types/shape';
|
||||
|
||||
@@ -25,6 +25,14 @@ export type IWidget<TKey extends string, TDefinition extends IWidgetDefinition>
|
||||
shape: ShapeType;
|
||||
};
|
||||
|
||||
export type InferWidget<TDefinition extends IWidgetDefinition> = WidgetItem & {
|
||||
options: {
|
||||
[key in keyof TDefinition['options']]: MakeLessSpecific<
|
||||
TDefinition['options'][key]['defaultValue']
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
// Makes the type less specific
|
||||
// For example when the type true is used as input the result is boolean
|
||||
// By not using this type the definition would always be { property: true }
|
||||
|
||||
40
yarn.lock
40
yarn.lock
@@ -1030,7 +1030,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/core@npm:^6.0.0":
|
||||
"@mantine/core@npm:^6.0.21":
|
||||
version: 6.0.21
|
||||
resolution: "@mantine/core@npm:6.0.21"
|
||||
dependencies:
|
||||
@@ -1048,7 +1048,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/dates@npm:^6.0.0":
|
||||
"@mantine/dates@npm:^6.0.21":
|
||||
version: 6.0.21
|
||||
resolution: "@mantine/dates@npm:6.0.21"
|
||||
dependencies:
|
||||
@@ -1062,7 +1062,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/dropzone@npm:^6.0.0":
|
||||
"@mantine/dropzone@npm:^6.0.21":
|
||||
version: 6.0.21
|
||||
resolution: "@mantine/dropzone@npm:6.0.21"
|
||||
dependencies:
|
||||
@@ -1077,7 +1077,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/form@npm:^6.0.0":
|
||||
"@mantine/form@npm:^6.0.21":
|
||||
version: 6.0.21
|
||||
resolution: "@mantine/form@npm:6.0.21"
|
||||
dependencies:
|
||||
@@ -1089,7 +1089,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/hooks@npm:^6.0.0":
|
||||
"@mantine/hooks@npm:^6.0.21":
|
||||
version: 6.0.21
|
||||
resolution: "@mantine/hooks@npm:6.0.21"
|
||||
peerDependencies:
|
||||
@@ -1098,7 +1098,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/modals@npm:^6.0.0":
|
||||
"@mantine/modals@npm:^6.0.21":
|
||||
version: 6.0.21
|
||||
resolution: "@mantine/modals@npm:6.0.21"
|
||||
dependencies:
|
||||
@@ -1112,7 +1112,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/next@npm:^6.0.0":
|
||||
"@mantine/next@npm:^6.0.21":
|
||||
version: 6.0.21
|
||||
resolution: "@mantine/next@npm:6.0.21"
|
||||
dependencies:
|
||||
@@ -1126,7 +1126,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/notifications@npm:^6.0.0":
|
||||
"@mantine/notifications@npm:^6.0.21":
|
||||
version: 6.0.21
|
||||
resolution: "@mantine/notifications@npm:6.0.21"
|
||||
dependencies:
|
||||
@@ -1141,7 +1141,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/prism@npm:^6.0.19":
|
||||
"@mantine/prism@npm:^6.0.21":
|
||||
version: 6.0.21
|
||||
resolution: "@mantine/prism@npm:6.0.21"
|
||||
dependencies:
|
||||
@@ -1185,7 +1185,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/tiptap@npm:^6.0.17":
|
||||
"@mantine/tiptap@npm:^6.0.21":
|
||||
version: 6.0.21
|
||||
resolution: "@mantine/tiptap@npm:6.0.21"
|
||||
dependencies:
|
||||
@@ -7566,16 +7566,16 @@ __metadata:
|
||||
"@emotion/react": ^11.10.6
|
||||
"@emotion/server": ^11.10.0
|
||||
"@jellyfin/sdk": ^0.8.0
|
||||
"@mantine/core": ^6.0.0
|
||||
"@mantine/dates": ^6.0.0
|
||||
"@mantine/dropzone": ^6.0.0
|
||||
"@mantine/form": ^6.0.0
|
||||
"@mantine/hooks": ^6.0.0
|
||||
"@mantine/modals": ^6.0.0
|
||||
"@mantine/next": ^6.0.0
|
||||
"@mantine/notifications": ^6.0.0
|
||||
"@mantine/prism": ^6.0.19
|
||||
"@mantine/tiptap": ^6.0.17
|
||||
"@mantine/core": ^6.0.21
|
||||
"@mantine/dates": ^6.0.21
|
||||
"@mantine/dropzone": ^6.0.21
|
||||
"@mantine/form": ^6.0.21
|
||||
"@mantine/hooks": ^6.0.21
|
||||
"@mantine/modals": ^6.0.21
|
||||
"@mantine/next": ^6.0.21
|
||||
"@mantine/notifications": ^6.0.21
|
||||
"@mantine/prism": ^6.0.21
|
||||
"@mantine/tiptap": ^6.0.21
|
||||
"@next/bundle-analyzer": ^13.0.0
|
||||
"@next/eslint-plugin-next": ^13.4.5
|
||||
"@nivo/core": ^0.83.0
|
||||
|
||||
Reference in New Issue
Block a user