🚧 Add working loading of board, add temporary input to manage page to add boards

This commit is contained in:
Meier Lukas
2023-10-01 00:11:05 +02:00
parent df847b57f8
commit e7ab19c622
35 changed files with 674 additions and 418 deletions

View File

@@ -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 @@
]
}
}
}
}

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

View File

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

View File

@@ -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),

View File

@@ -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',

View File

@@ -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
}
/>

View File

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

View File

@@ -1,3 +1,3 @@
import { DashboardView } from './DashboardView';
import { BoardView } from './DashboardView';
export const DashboardDetailView = () => <DashboardView />;
export const DashboardDetailView = () => <BoardView />;

View File

@@ -1,3 +1,3 @@
import { DashboardView } from './DashboardView';
import { BoardView } from './DashboardView';
export const DashboardEditView = () => <DashboardView />;
export const DashboardEditView = () => <BoardView />;

View File

@@ -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>

View File

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

View File

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

View File

@@ -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 = {

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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
//

View File

@@ -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) {

View File

@@ -1,5 +1,4 @@
import { MantineGradient } from '@mantine/core';
import { useColorTheme } from '~/tools/color';
export const usePrimaryGradient = () => {

View File

@@ -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>

View File

@@ -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',

View File

@@ -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 = {

View File

@@ -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,

View File

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

View File

@@ -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,

View File

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

View File

@@ -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,

View File

@@ -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(),

View File

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

View File

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

View File

@@ -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

View File

@@ -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>

View File

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

View File

@@ -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>

View File

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

View File

@@ -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