feat(boards): add responsive layout system (#2271)

This commit is contained in:
Meier Lukas
2025-02-23 17:34:56 +01:00
committed by GitHub
parent 2085b5ece2
commit 7761dc29c8
98 changed files with 11770 additions and 1694 deletions

View File

@@ -29,7 +29,7 @@ export const analyseOldmarrImportAsync = async (file: File) => {
}
return {
name: entry.name,
name: entry.name.replace(".json", ""),
config: result.data ?? null,
isError: !result.success,
};

View File

@@ -4,7 +4,6 @@ import SuperJSON from "superjson";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { boardSizes } from "@homarr/old-schema";
// We don't have access to the API client here, so we need to import it from the API package
// In the future we should consider having the used router also in this package
@@ -13,7 +12,6 @@ import type { AnalyseResult } from "../analyse/analyse-oldmarr-import";
import { prepareMultipleImports } from "../prepare/prepare-multiple";
import type { InitialOldmarrImportSettings } from "../settings";
import { defaultSidebarBehaviour } from "../settings";
import type { BoardSelectionMap, BoardSizeRecord } from "./initial/board-selection-card";
import { BoardSelectionCard } from "./initial/board-selection-card";
import { ImportSettingsCard } from "./initial/import-settings-card";
import { ImportSummaryCard } from "./initial/import-summary-card";
@@ -25,8 +23,8 @@ interface InitialOldmarrImportProps {
}
export const InitialOldmarrImport = ({ file, analyseResult }: InitialOldmarrImportProps) => {
const [boardSelections, setBoardSelections] = useState<BoardSelectionMap>(
new Map(createDefaultSelections(analyseResult.configs)),
const [boardSelections, setBoardSelections] = useState<Map<string, boolean>>(
new Map(analyseResult.configs.filter(({ config }) => config !== null).map(({ name }) => [name, true])),
);
const [settings, setSettings] = useState<InitialOldmarrImportSettings>({
onlyImportApps: false,
@@ -94,19 +92,3 @@ export const InitialOldmarrImport = ({ file, analyseResult }: InitialOldmarrImpo
</Stack>
);
};
const createDefaultSelections = (configs: AnalyseResult["configs"]) => {
return configs
.map(({ name, config }) => {
if (!config) return null;
const shapes = config.apps.flatMap((app) => app.shape).concat(config.widgets.flatMap((widget) => widget.shape));
const boardSizeRecord = boardSizes.reduce<BoardSizeRecord>((acc, size) => {
const allInclude = shapes.every((shape) => Boolean(shape[size]));
acc[size] = allInclude ? true : null;
return acc;
}, {} as BoardSizeRecord);
return [name, boardSizeRecord];
})
.filter((selection): selection is [string, BoardSizeRecord] => Boolean(selection));
};

View File

@@ -1,14 +1,9 @@
import type { ChangeEvent } from "react";
import { Anchor, Card, Checkbox, Group, Stack, Text } from "@mantine/core";
import { objectEntries, objectKeys } from "@homarr/common";
import { boardSizes } from "@homarr/old-schema";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
type BoardSize = (typeof boardSizes)[number];
export type BoardSizeRecord = Record<BoardSize, boolean | null>;
export type BoardSelectionMap = Map<string, BoardSizeRecord>;
export type BoardSelectionMap = Map<string, boolean>;
interface BoardSelectionCardProps {
selections: BoardSelectionMap;
@@ -16,12 +11,9 @@ interface BoardSelectionCardProps {
}
const allChecked = (map: BoardSelectionMap) => {
return [...map.values()].every((selection) => groupChecked(selection));
return [...map.values()].every((selection) => selection);
};
const groupChecked = (selection: BoardSizeRecord) =>
objectEntries(selection).every(([_, value]) => value === true || value === null);
export const BoardSelectionCard = ({ selections, updateSelections }: BoardSelectionCardProps) => {
const tBoardSelection = useScopedI18n("init.step.import.boardSelection");
const t = useI18n();
@@ -29,50 +21,14 @@ export const BoardSelectionCard = ({ selections, updateSelections }: BoardSelect
const handleToggleAll = () => {
updateSelections((selections) => {
const updated = new Map(selections);
[...selections.entries()].forEach(([name, selection]) => {
objectKeys(selection).forEach((size) => {
if (selection[size] === null) return;
selection[size] = !areAllChecked;
});
updated.set(name, selection);
});
return updated;
return new Map([...selections.keys()].map((name) => [name, !areAllChecked] as const));
});
};
const registerToggleGroup = (name: string) => (event: ChangeEvent<HTMLInputElement>) => {
const registerToggle = (name: string) => (event: ChangeEvent<HTMLInputElement>) => {
updateSelections((selections) => {
const updated = new Map(selections);
const selection = selections.get(name);
if (!selection) return updated;
objectKeys(selection).forEach((size) => {
if (selection[size] === null) return;
selection[size] = event.target.checked;
});
updated.set(name, selection);
return updated;
});
};
const registerToggle = (name: string, size: BoardSize) => (event: ChangeEvent<HTMLInputElement>) => {
updateSelections((selections) => {
const updated = new Map(selections);
const selection = selections.get(name);
if (!selection) return updated;
selection[size] = event.target.checked;
updated.set(name, selection);
updated.set(name, event.target.checked);
return updated;
});
};
@@ -100,53 +56,17 @@ export const BoardSelectionCard = ({ selections, updateSelections }: BoardSelect
</Stack>
<Stack gap="sm">
{[...selections.entries()].map(([name, selection]) => (
{[...selections.entries()].map(([name, selected]) => (
<Card key={name} withBorder>
<Group justify="space-between" align="center" visibleFrom="md">
<Checkbox
checked={groupChecked(selection)}
onChange={registerToggleGroup(name)}
label={
<Text fw={500} size="sm">
{name}
</Text>
}
/>
<Group>
{boardSizes.map((size) => (
<Checkbox
key={size}
disabled={selection[size] === null}
checked={selection[size] ?? undefined}
onChange={registerToggle(name, size)}
label={t(`board.action.oldImport.form.screenSize.option.${size}`)}
/>
))}
</Group>
</Group>
<Stack hiddenFrom="md">
<Checkbox
checked={groupChecked(selection)}
onChange={registerToggleGroup(name)}
label={
<Text fw={500} size="sm">
{name}
</Text>
}
/>
<Stack gap="sm" ps="sm">
{objectEntries(selection)
.filter(([_, value]) => value !== null)
.map(([size, value]) => (
<Checkbox
key={size}
checked={value ?? undefined}
onChange={registerToggle(name, size)}
label={`screenSize.${size}`}
/>
))}
</Stack>
</Stack>
<Checkbox
checked={selected}
onChange={registerToggle(name)}
label={
<Text fw={500} size="sm">
{name}
</Text>
}
/>
</Card>
))}
</Stack>

View File

@@ -1,160 +0,0 @@
import { createId, inArray } from "@homarr/db";
import type { Database, InferInsertModel, InferSelectModel } from "@homarr/db";
import { apps as appsTable } from "@homarr/db/schema";
import { logger } from "@homarr/log";
import type { OldmarrApp } from "@homarr/old-schema";
import type { BookmarkApp } from "./widgets/definitions/bookmark";
type DbAppWithoutId = Omit<InferSelectModel<typeof appsTable>, "id">;
interface AppMapping extends DbAppWithoutId {
ids: string[];
newId: string;
exists: boolean;
}
export const insertAppsAsync = async (
db: Database,
apps: OldmarrApp[],
bookmarkApps: BookmarkApp[],
distinctAppsByHref: boolean,
configName: string,
) => {
logger.info(
`Importing old homarr apps configuration=${configName} distinctAppsByHref=${distinctAppsByHref} apps=${apps.length}`,
);
const existingAppsWithHref = distinctAppsByHref
? await db.query.apps.findMany({
where: inArray(appsTable.href, [
...new Set(apps.map((app) => app.url).concat(bookmarkApps.map((app) => app.href))),
]),
})
: [];
logger.debug(`Found existing apps with href count=${existingAppsWithHref.length}`);
// Generate mappings for all apps from old to new ids
const appMappings: AppMapping[] = [];
addMappingFor(apps, appMappings, existingAppsWithHref, convertApp);
addMappingFor(bookmarkApps, appMappings, existingAppsWithHref, convertBookmarkApp);
logger.debug(`Mapping apps count=${appMappings.length}`);
const appsToCreate = appMappings
.filter((app) => !app.exists)
.map(
(app) =>
({
id: app.newId,
name: app.name,
iconUrl: app.iconUrl,
href: app.href,
description: app.description,
}) satisfies InferInsertModel<typeof appsTable>,
);
logger.debug(`Creating apps count=${appsToCreate.length}`);
if (appsToCreate.length > 0) {
await db.insert(appsTable).values(appsToCreate);
}
logger.info(`Imported apps count=${appsToCreate.length}`);
// Generates a map from old key to new key for all apps
return new Map(
appMappings
.map((app) => app.ids.map((id) => ({ id, newId: app.newId })))
.flat()
.map(({ id, newId }) => [id, newId]),
);
};
/**
* Creates a callback to be used in a find method that compares the old app with the new app
* @param app either an oldmarr app or a bookmark app
* @param convertApp a function that converts the app to a new app
* @returns a callback that compares the old app with the new app and returns true if they are the same
*/
const createFindCallback = <TApp extends OldmarrApp | BookmarkApp>(
app: TApp,
convertApp: (app: TApp) => DbAppWithoutId,
) => {
const oldApp = convertApp(app);
return (dbApp: DbAppWithoutId) =>
oldApp.href === dbApp.href &&
oldApp.name === dbApp.name &&
oldApp.iconUrl === dbApp.iconUrl &&
oldApp.description === dbApp.description;
};
/**
* Adds mappings for the given apps to the appMappings array
* @param apps apps to add mappings for
* @param appMappings existing app mappings
* @param existingAppsWithHref existing apps with href
* @param convertApp a function that converts the app to a new app
*/
const addMappingFor = <TApp extends OldmarrApp | BookmarkApp>(
apps: TApp[],
appMappings: AppMapping[],
existingAppsWithHref: InferSelectModel<typeof appsTable>[],
convertApp: (app: TApp) => DbAppWithoutId,
) => {
for (const app of apps) {
const previous = appMappings.find(createFindCallback(app, convertApp));
if (previous) {
previous.ids.push(app.id);
continue;
}
const existing = existingAppsWithHref.find(createFindCallback(app, convertApp));
if (existing) {
appMappings.push({
ids: [app.id],
newId: existing.id,
name: existing.name,
href: existing.href,
iconUrl: existing.iconUrl,
description: existing.description,
pingUrl: existing.pingUrl,
exists: true,
});
continue;
}
appMappings.push({
ids: [app.id],
newId: createId(),
...convertApp(app),
exists: false,
});
}
};
/**
* Converts an oldmarr app to a new app
* @param app oldmarr app
* @returns new app
*/
const convertApp = (app: OldmarrApp): DbAppWithoutId => ({
name: app.name,
href: app.behaviour.externalUrl === "" ? app.url : app.behaviour.externalUrl,
iconUrl: app.appearance.iconUrl,
description: app.behaviour.tooltipDescription ?? null,
pingUrl: app.url.length > 0 ? app.url : null,
});
/**
* Converts a bookmark app to a new app
* @param app bookmark app
* @returns new app
*/
const convertBookmarkApp = (app: BookmarkApp): DbAppWithoutId => ({
...app,
description: null,
pingUrl: null,
});

View File

@@ -1,35 +0,0 @@
import type { Database } from "@homarr/db";
import { createId } from "@homarr/db";
import { boards } from "@homarr/db/schema";
import { logger } from "@homarr/log";
import type { OldmarrConfig } from "@homarr/old-schema";
import { mapColor } from "./mappers/map-colors";
import { mapColumnCount } from "./mappers/map-column-count";
import type { OldmarrImportConfiguration } from "./settings";
export const insertBoardAsync = async (db: Database, old: OldmarrConfig, configuration: OldmarrImportConfiguration) => {
logger.info(`Importing old homarr board configuration=${old.configProperties.name}`);
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: configuration.name,
backgroundImageAttachment: old.settings.customization.backgroundImageAttachment,
backgroundImageUrl: old.settings.customization.backgroundImageUrl,
backgroundImageRepeat: old.settings.customization.backgroundImageRepeat,
backgroundImageSize: old.settings.customization.backgroundImageSize,
columnCount: mapColumnCount(old, configuration.screenSize),
faviconImageUrl: old.settings.customization.faviconUrl,
isPublic: old.settings.access.allowGuests,
logoImageUrl: old.settings.customization.logoImageUrl,
pageTitle: old.settings.customization.pageTitle,
metaTitle: old.settings.customization.metaTitle,
opacity: old.settings.customization.appOpacity,
primaryColor: mapColor(old.settings.customization.colors.primary, "#fa5252"),
secondaryColor: mapColor(old.settings.customization.colors.secondary, "#fd7e14"),
});
logger.info(`Imported board id=${boardId}`);
return boardId;
};

View File

@@ -1,6 +1,4 @@
import type { OldmarrConfig } from "@homarr/old-schema";
import type { OldmarrImportConfiguration } from "./settings";
import type { BoardSize, OldmarrConfig } from "@homarr/old-schema";
export class OldHomarrImportError extends Error {
constructor(oldConfig: OldmarrConfig, cause: unknown) {
@@ -11,7 +9,7 @@ export class OldHomarrImportError extends Error {
}
export class OldHomarrScreenSizeError extends Error {
constructor(type: "app" | "widget", id: string, screenSize: OldmarrImportConfiguration["screenSize"]) {
constructor(type: "app" | "widget", id: string, screenSize: BoardSize) {
super(`Screen size not found for type=${type} id=${id} screenSize=${screenSize}`);
}
}

View File

@@ -1,101 +0,0 @@
import SuperJSON from "superjson";
import type { Database } from "@homarr/db";
import { createId } from "@homarr/db";
import { items } from "@homarr/db/schema";
import { logger } from "@homarr/log";
import type { OldmarrApp, OldmarrWidget } from "@homarr/old-schema";
import type { WidgetComponentProps } from "../../widgets/src/definition";
import { OldHomarrScreenSizeError } from "./import-error";
import type { OldmarrImportConfiguration } from "./settings";
import { mapKind } from "./widgets/definitions";
import { mapOptions } from "./widgets/options";
export const insertItemsAsync = async (
db: Database,
widgets: OldmarrWidget[],
apps: OldmarrApp[],
appsMap: Map<string, string>,
sectionIdMaps: Map<string, string>,
configuration: OldmarrImportConfiguration,
) => {
logger.info(`Importing old homarr items widgets=${widgets.length} apps=${apps.length}`);
for (const widget of widgets) {
// All items should have been moved to the last wrapper
if (widget.area.type === "sidebar") {
continue;
}
const kind = mapKind(widget.type);
logger.debug(`Mapped widget kind id=${widget.id} previous=${widget.type} current=${kind}`);
if (!kind) {
logger.error(`Widget has no kind id=${widget.id} type=${widget.type}`);
continue;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const sectionId = sectionIdMaps.get(widget.area.properties.id)!;
logger.debug(`Inserting widget id=${widget.id} sectionId=${sectionId}`);
const screenSizeShape = widget.shape[configuration.screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("widget", widget.id, configuration.screenSize);
}
await db.insert(items).values({
id: createId(),
sectionId,
height: screenSizeShape.size.height,
width: screenSizeShape.size.width,
xOffset: screenSizeShape.location.x,
yOffset: screenSizeShape.location.y,
kind,
options: SuperJSON.stringify(mapOptions(widget.type, widget.properties, appsMap)),
});
logger.debug(`Inserted widget id=${widget.id} sectionId=${sectionId}`);
}
for (const app of apps) {
// All items should have been moved to the last wrapper
if (app.area.type === "sidebar") {
continue;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const sectionId = sectionIdMaps.get(app.area.properties.id)!;
logger.debug(`Inserting app name=${app.name} sectionId=${sectionId}`);
const screenSizeShape = app.shape[configuration.screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("app", app.id, configuration.screenSize);
}
await db.insert(items).values({
id: createId(),
sectionId,
height: screenSizeShape.size.height,
width: screenSizeShape.size.width,
xOffset: screenSizeShape.location.x,
yOffset: screenSizeShape.location.y,
kind: "app",
options: SuperJSON.stringify({
// it's safe to assume that the app exists in the map
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
appId: appsMap.get(app.id)!,
openInNewTab: app.behaviour.isOpeningNewTab,
pingEnabled: app.network.enabledStatusChecker,
showDescriptionTooltip: app.behaviour.tooltipDescription !== "",
showTitle: app.appearance.appNameStatus === "normal",
} satisfies WidgetComponentProps<"app">["options"]),
});
logger.debug(`Inserted app name=${app.name} sectionId=${sectionId}`);
}
};

View File

@@ -1,20 +1,31 @@
import { createId } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import { logger } from "@homarr/log";
import type { BoardSize } from "@homarr/old-schema";
import { boardSizes, getBoardSizeName } from "@homarr/old-schema";
import { fixSectionIssues } from "../../fix-section-issues";
import { mapBoard } from "../../mappers/map-board";
import { mapBreakpoint } from "../../mappers/map-breakpoint";
import { mapColumnCount } from "../../mappers/map-column-count";
import { moveWidgetsAndAppsIfMerge } from "../../move-widgets-and-apps-merge";
import { prepareItems } from "../../prepare/prepare-items";
import type { prepareMultipleImports } from "../../prepare/prepare-multiple";
import { prepareSections } from "../../prepare/prepare-sections";
import type { InitialOldmarrImportSettings } from "../../settings";
import { createDbInsertCollection } from "./common";
export const createBoardInsertCollection = (
{ preparedApps, preparedBoards }: Omit<ReturnType<typeof prepareMultipleImports>, "preparedIntegrations">,
settings: InitialOldmarrImportSettings,
) => {
const insertCollection = createDbInsertCollection(["apps", "boards", "sections", "items"]);
const insertCollection = createDbInsertCollectionForTransaction([
"apps",
"boards",
"layouts",
"sections",
"items",
"itemLayouts",
]);
logger.info("Preparing boards for insert collection");
const appsMap = new Map(
@@ -49,7 +60,6 @@ export const createBoardInsertCollection = (
const { wrappers, categories, wrapperIdsToMerge } = fixSectionIssues(board.config);
const { apps, widgets } = moveWidgetsAndAppsIfMerge(board.config, wrapperIdsToMerge, {
...settings,
screenSize: board.size,
name: board.name,
});
@@ -58,6 +68,25 @@ export const createBoardInsertCollection = (
const mappedBoard = mapBoard(board);
logger.debug(`Mapped board fileName=${board.name} boardId=${mappedBoard.id}`);
insertCollection.boards.push(mappedBoard);
const layoutMapping = boardSizes.reduce(
(acc, size) => {
acc[size] = createId();
return acc;
},
{} as Record<BoardSize, string>,
);
insertCollection.layouts.push(
...boardSizes.map((size) => ({
id: layoutMapping[size],
boardId: mappedBoard.id,
columnCount: mapColumnCount(board.config, size),
breakpoint: mapBreakpoint(size),
name: getBoardSizeName(size),
})),
);
const preparedSections = prepareSections(mappedBoard.id, { wrappers, categories });
for (const section of preparedSections.values()) {
@@ -65,8 +94,11 @@ export const createBoardInsertCollection = (
}
logger.debug(`Added sections to board insert collection count=${insertCollection.sections.length}`);
const preparedItems = prepareItems({ apps, widgets }, board.size, appsMap, preparedSections);
preparedItems.forEach((item) => insertCollection.items.push(item));
const preparedItems = prepareItems({ apps, widgets }, appsMap, preparedSections, layoutMapping, mappedBoard.id);
preparedItems.forEach(({ layouts, ...item }) => {
insertCollection.items.push(item);
insertCollection.itemLayouts.push(...layouts);
});
logger.debug(`Added items to board insert collection count=${insertCollection.items.length}`);
});

View File

@@ -1,43 +0,0 @@
import { objectEntries } from "@homarr/common";
import type { Database, HomarrDatabaseMysql, InferInsertModel } from "@homarr/db";
import * as schema from "@homarr/db/schema";
type TableKey = {
[K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never;
}[keyof typeof schema];
export const createDbInsertCollection = <TTableKey extends TableKey>(tablesInInsertOrder: TTableKey[]) => {
const context = tablesInInsertOrder.reduce(
(acc, key) => {
acc[key] = [];
return acc;
},
{} as { [K in TTableKey]: InferInsertModel<(typeof schema)[K]>[] },
);
return {
...context,
insertAll: (db: Database) => {
db.transaction((transaction) => {
for (const [key, values] of objectEntries(context)) {
if (values.length >= 1) {
transaction
.insert(schema[key])
.values(values as never)
.run();
}
}
});
},
insertAllAsync: async (db: HomarrDatabaseMysql) => {
await db.transaction(async (transaction) => {
for (const [key, values] of objectEntries(context)) {
if (values.length >= 1) {
// Below is actually the mysqlSchema when the driver is mysql
await transaction.insert(schema[key] as never).values(values as never);
}
}
});
},
};
};

View File

@@ -1,15 +1,15 @@
import { encryptSecret } from "@homarr/common/server";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import { logger } from "@homarr/log";
import { mapAndDecryptIntegrations } from "../../mappers/map-integration";
import type { PreparedIntegration } from "../../prepare/prepare-integrations";
import { createDbInsertCollection } from "./common";
export const createIntegrationInsertCollection = (
preparedIntegrations: PreparedIntegration[],
encryptionToken: string | null | undefined,
) => {
const insertCollection = createDbInsertCollection(["integrations", "integrationSecrets"]);
const insertCollection = createDbInsertCollectionForTransaction(["integrations", "integrationSecrets"]);
if (preparedIntegrations.length === 0) {
return insertCollection;

View File

@@ -1,16 +1,21 @@
import { createId } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import { credentialsAdminGroup } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { mapAndDecryptUsers } from "../../mappers/map-user";
import type { OldmarrImportUser } from "../../user-schema";
import { createDbInsertCollection } from "./common";
export const createUserInsertCollection = (
importUsers: OldmarrImportUser[],
encryptionToken: string | null | undefined,
) => {
const insertCollection = createDbInsertCollection(["users", "groups", "groupMembers", "groupPermissions"]);
const insertCollection = createDbInsertCollectionForTransaction([
"users",
"groups",
"groupMembers",
"groupPermissions",
]);
if (importUsers.length === 0) {
return insertCollection;

View File

@@ -4,14 +4,7 @@ import { zfd } from "zod-form-data";
import { initialOldmarrImportSettings } from "../settings";
const boardSelectionMapSchema = z.map(
z.string(),
z.object({
sm: z.boolean().nullable(),
md: z.boolean().nullable(),
lg: z.boolean().nullable(),
}),
);
const boardSelectionMapSchema = z.map(z.string(), z.boolean());
export const importInitialOldmarrInputSchema = zfd.formData({
file: zfd.file(),

View File

@@ -4,7 +4,6 @@ import type { boards } from "@homarr/db/schema";
import type { prepareMultipleImports } from "../prepare/prepare-multiple";
import { mapColor } from "./map-colors";
import { mapColumnCount } from "./map-column-count";
type PreparedBoard = ReturnType<typeof prepareMultipleImports>["preparedBoards"][number];
@@ -15,7 +14,6 @@ export const mapBoard = (preparedBoard: PreparedBoard): InferInsertModel<typeof
backgroundImageUrl: preparedBoard.config.settings.customization.backgroundImageUrl,
backgroundImageRepeat: preparedBoard.config.settings.customization.backgroundImageRepeat,
backgroundImageSize: preparedBoard.config.settings.customization.backgroundImageSize,
columnCount: mapColumnCount(preparedBoard.config, preparedBoard.size),
faviconImageUrl: preparedBoard.config.settings.customization.faviconUrl,
isPublic: preparedBoard.config.settings.access.allowGuests,
logoImageUrl: preparedBoard.config.settings.customization.logoImageUrl,

View File

@@ -0,0 +1,18 @@
import type { BoardSize } from "@homarr/old-schema";
/**
* Copied from https://github.com/ajnart/homarr/blob/274eaa92084a8be4d04a69a87f9920860a229128/src/components/Dashboard/Wrappers/gridstack/store.tsx#L21-L30
* @param screenSize board size
* @returns layout breakpoint for the board
*/
export const mapBreakpoint = (screenSize: BoardSize) => {
switch (screenSize) {
case "lg":
return 1400;
case "md":
return 800;
case "sm":
default:
return 0;
}
};

View File

@@ -1,8 +1,6 @@
import type { OldmarrConfig } from "@homarr/old-schema";
import type { BoardSize, OldmarrConfig } from "@homarr/old-schema";
import type { OldmarrImportConfiguration } from "../settings";
export const mapColumnCount = (old: OldmarrConfig, screenSize: OldmarrImportConfiguration["screenSize"]) => {
export const mapColumnCount = (old: OldmarrConfig, screenSize: BoardSize) => {
switch (screenSize) {
case "lg":
return old.settings.customization.gridstack.columnCountLarge;

View File

@@ -2,9 +2,10 @@ import SuperJSON from "superjson";
import type { InferInsertModel } from "@homarr/db";
import { createId } from "@homarr/db";
import type { items } from "@homarr/db/schema";
import type { itemLayouts, items } from "@homarr/db/schema";
import { logger } from "@homarr/log";
import type { BoardSize, OldmarrApp, OldmarrWidget } from "@homarr/old-schema";
import { boardSizes } from "@homarr/old-schema";
import type { WidgetComponentProps } from "../../../widgets/src/definition";
import { mapKind } from "../widgets/definitions";
@@ -12,30 +13,23 @@ import { mapOptions } from "../widgets/options";
export const mapApp = (
app: OldmarrApp,
boardSize: BoardSize,
appsMap: Map<string, { id: string }>,
sectionMap: Map<string, { id: string }>,
): InferInsertModel<typeof items> | null => {
layoutMap: Record<BoardSize, string>,
boardId: string,
): (InferInsertModel<typeof items> & { layouts: InferInsertModel<typeof itemLayouts>[] }) | null => {
if (app.area.type === "sidebar") throw new Error("Mapping app in sidebar is not supported");
const shapeForSize = app.shape[boardSize];
if (!shapeForSize) {
throw new Error(`Failed to find a shape for appId='${app.id}' screenSize='${boardSize}'`);
}
const sectionId = sectionMap.get(app.area.properties.id)?.id;
if (!sectionId) {
logger.warn(`Failed to find section for app appId='${app.id}' sectionId='${app.area.properties.id}'. Removing app`);
return null;
}
const itemId = createId();
return {
id: createId(),
sectionId,
height: shapeForSize.size.height,
width: shapeForSize.size.width,
xOffset: shapeForSize.location.x,
yOffset: shapeForSize.location.y,
id: itemId,
boardId,
kind: "app",
options: SuperJSON.stringify({
// it's safe to assume that the app exists in the map
@@ -46,22 +40,34 @@ export const mapApp = (
showDescriptionTooltip: app.behaviour.tooltipDescription !== "",
showTitle: app.appearance.appNameStatus === "normal",
} satisfies WidgetComponentProps<"app">["options"]),
layouts: boardSizes.map((size) => {
const shapeForSize = app.shape[size];
if (!shapeForSize) {
throw new Error(`Failed to find a shape for appId='${app.id}' screenSize='${size}'`);
}
return {
itemId,
height: shapeForSize.size.height,
width: shapeForSize.size.width,
xOffset: shapeForSize.location.x,
yOffset: shapeForSize.location.y,
sectionId,
layoutId: layoutMap[size],
};
}),
};
};
export const mapWidget = (
widget: OldmarrWidget,
boardSize: BoardSize,
appsMap: Map<string, { id: string }>,
sectionMap: Map<string, { id: string }>,
): InferInsertModel<typeof items> | null => {
layoutMap: Record<BoardSize, string>,
boardId: string,
): (InferInsertModel<typeof items> & { layouts: InferInsertModel<typeof itemLayouts>[] }) | null => {
if (widget.area.type === "sidebar") throw new Error("Mapping widget in sidebar is not supported");
const shapeForSize = widget.shape[boardSize];
if (!shapeForSize) {
throw new Error(`Failed to find a shape for widgetId='${widget.id}' screenSize='${boardSize}'`);
}
const kind = mapKind(widget.type);
if (!kind) {
logger.warn(`Failed to map widget type='${widget.type}'. It's no longer supported`);
@@ -76,13 +82,10 @@ export const mapWidget = (
return null;
}
const itemId = createId();
return {
id: createId(),
sectionId,
height: shapeForSize.size.height,
width: shapeForSize.size.width,
xOffset: shapeForSize.location.x,
yOffset: shapeForSize.location.y,
id: itemId,
boardId,
kind,
options: SuperJSON.stringify(
mapOptions(
@@ -91,5 +94,21 @@ export const mapWidget = (
new Map([...appsMap.entries()].map(([key, value]) => [key, value.id])),
),
),
layouts: boardSizes.map((size) => {
const shapeForSize = widget.shape[size];
if (!shapeForSize) {
throw new Error(`Failed to find a shape for widgetId='${widget.id}' screenSize='${size}'`);
}
return {
itemId,
height: shapeForSize.size.height,
width: shapeForSize.size.width,
xOffset: shapeForSize.location.x,
yOffset: shapeForSize.location.y,
sectionId,
layoutId: layoutMap[size],
};
}),
};
};

View File

@@ -1,6 +1,7 @@
import { objectEntries } from "@homarr/common";
import { logger } from "@homarr/log";
import type { OldmarrApp, OldmarrConfig, OldmarrWidget } from "@homarr/old-schema";
import type { BoardSize, OldmarrApp, OldmarrConfig, OldmarrWidget } from "@homarr/old-schema";
import { boardSizes } from "@homarr/old-schema";
import { OldHomarrScreenSizeError } from "./import-error";
import { mapColumnCount } from "./mappers/map-column-count";
@@ -28,9 +29,21 @@ export const moveWidgetsAndAppsIfMerge = (
logger.debug(`Merging wrappers at the end of the board count=${wrapperIdsToMerge.length}`);
let offset = 0;
const offsets = boardSizes.reduce(
(previous, screenSize) => {
previous[screenSize] = 0;
return previous;
},
{} as Record<BoardSize, number>,
);
for (const id of wrapperIdsToMerge) {
let requiredHeight = 0;
const requiredHeights = boardSizes.reduce(
(previous, screenSize) => {
previous[screenSize] = 0;
return previous;
},
{} as Record<BoardSize, number>,
);
const affected = affectedMap.get(id);
if (!affected) {
continue;
@@ -44,18 +57,20 @@ export const moveWidgetsAndAppsIfMerge = (
// Move item to first wrapper
app.area.properties.id = firstId;
const screenSizeShape = app.shape[configuration.screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("app", app.id, configuration.screenSize);
}
for (const screenSize of boardSizes) {
const screenSizeShape = app.shape[screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("app", app.id, screenSize);
}
// Find the highest widget in the wrapper to increase the offset accordingly
if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeight) {
requiredHeight = screenSizeShape.location.y + screenSizeShape.size.height;
}
// Find the highest widget in the wrapper to increase the offset accordingly
if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeights[screenSize]) {
requiredHeights[screenSize] = screenSizeShape.location.y + screenSizeShape.size.height;
}
// Move item down as much as needed to not overlap with other items
screenSizeShape.location.y += offset;
// Move item down as much as needed to not overlap with other items
screenSizeShape.location.y += offsets[screenSize];
}
}
for (const widget of widgets) {
@@ -63,21 +78,25 @@ export const moveWidgetsAndAppsIfMerge = (
// Move item to first wrapper
widget.area.properties.id = firstId;
const screenSizeShape = widget.shape[configuration.screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("widget", widget.id, configuration.screenSize);
}
for (const screenSize of boardSizes) {
const screenSizeShape = widget.shape[screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("widget", widget.id, screenSize);
}
// Find the highest widget in the wrapper to increase the offset accordingly
if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeight) {
requiredHeight = screenSizeShape.location.y + screenSizeShape.size.height;
}
// Find the highest widget in the wrapper to increase the offset accordingly
if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeights[screenSize]) {
requiredHeights[screenSize] = screenSizeShape.location.y + screenSizeShape.size.height;
}
// Move item down as much as needed to not overlap with other items
screenSizeShape.location.y += offset;
// Move item down as much as needed to not overlap with other items
screenSizeShape.location.y += offsets[screenSize];
}
}
offset += requiredHeight;
for (const screenSize of boardSizes) {
offsets[screenSize] += requiredHeights[screenSize];
}
}
if (configuration.sidebarBehaviour === "last-section") {
@@ -86,14 +105,18 @@ export const moveWidgetsAndAppsIfMerge = (
old.settings.customization.layout.enabledLeftSidebar ||
areas.some((area) => area.type === "sidebar" && area.properties.location === "left")
) {
offset = moveWidgetsAndAppsInLeftSidebar(old, firstId, offset, configuration.screenSize);
for (const screenSize of boardSizes) {
offsets[screenSize] = moveWidgetsAndAppsInLeftSidebar(old, firstId, offsets[screenSize], screenSize);
}
}
if (
old.settings.customization.layout.enabledRightSidebar ||
areas.some((area) => area.type === "sidebar" && area.properties.location === "right")
) {
moveWidgetsAndAppsInRightSidebar(old, firstId, offset, configuration.screenSize);
for (const screenSize of boardSizes) {
moveWidgetsAndAppsInRightSidebar(old, firstId, offsets[screenSize], screenSize);
}
}
} else {
// Remove all widgets and apps in the sidebar
@@ -110,7 +133,7 @@ const moveWidgetsAndAppsInLeftSidebar = (
old: OldmarrConfig,
firstId: string,
offset: number,
screenSize: OldmarrImportConfiguration["screenSize"],
screenSize: BoardSize,
) => {
const columnCount = mapColumnCount(old, screenSize);
let requiredHeight = updateItems({
@@ -186,7 +209,7 @@ const moveWidgetsAndAppsInRightSidebar = (
old: OldmarrConfig,
firstId: string,
offset: number,
screenSize: OldmarrImportConfiguration["screenSize"],
screenSize: BoardSize,
) => {
const columnCount = mapColumnCount(old, screenSize);
const xOffsetDelta = Math.max(columnCount - 2, 0);
@@ -255,10 +278,7 @@ const moveWidgetsAndAppsInRightSidebar = (
});
};
const createItemSnapshot = (
item: OldmarrApp | OldmarrWidget,
screenSize: OldmarrImportConfiguration["screenSize"],
) => ({
const createItemSnapshot = (item: OldmarrApp | OldmarrWidget, screenSize: BoardSize) => ({
x: item.shape[screenSize]?.location.x,
y: item.shape[screenSize]?.location.y,
height: item.shape[screenSize]?.size.height,
@@ -285,7 +305,7 @@ const updateItems = (options: {
items: (OldmarrApp | OldmarrWidget)[];
filter: (item: OldmarrApp | OldmarrWidget) => boolean;
update: (item: OldmarrApp | OldmarrWidget) => void;
screenSize: OldmarrImportConfiguration["screenSize"];
screenSize: BoardSize;
}) => {
const items = options.items.filter(options.filter);
let requiredHeight = 0;

View File

@@ -1,34 +1,6 @@
import { objectEntries } from "@homarr/common";
import type { BoardSize } from "@homarr/old-schema";
import type { ValidAnalyseConfig } from "../analyse/types";
import type { BoardSelectionMap } from "../components/initial/board-selection-card";
const boardSizeSuffix: Record<BoardSize, string> = {
lg: "large",
md: "medium",
sm: "small",
};
export const createBoardName = (fileName: string, boardSize: BoardSize) => {
return `${fileName.replace(".json", "")}-${boardSizeSuffix[boardSize]}`;
};
export const prepareBoards = (analyseConfigs: ValidAnalyseConfig[], selections: BoardSelectionMap) => {
return analyseConfigs.flatMap(({ name, config }) => {
const selectedSizes = selections.get(name);
if (!selectedSizes) return [];
return objectEntries(selectedSizes)
.map(([size, selected]) => {
if (!selected) return null;
return {
name: createBoardName(name, size),
size,
config,
};
})
.filter((board) => board !== null);
});
return analyseConfigs.filter(({ name }) => selections.get(name));
};

View File

@@ -4,11 +4,12 @@ import { mapApp, mapWidget } from "../mappers/map-item";
export const prepareItems = (
{ apps, widgets }: Pick<OldmarrConfig, "apps" | "widgets">,
boardSize: BoardSize,
appsMap: Map<string, { id: string }>,
sectionMap: Map<string, { id: string }>,
layoutMap: Record<BoardSize, string>,
boardId: string,
) =>
widgets
.map((widget) => mapWidget(widget, boardSize, appsMap, sectionMap))
.concat(apps.map((app) => mapApp(app, boardSize, appsMap, sectionMap)))
.map((widget) => mapWidget(widget, appsMap, sectionMap, layoutMap, boardId))
.concat(apps.map((app) => mapApp(app, appsMap, sectionMap, layoutMap, boardId)))
.filter((widget) => widget !== null);

View File

@@ -8,14 +8,6 @@ export const prepareSingleImport = (config: OldmarrConfig, settings: OldmarrImpo
return {
preparedApps: prepareApps(validAnalyseConfigs),
preparedBoards: settings.onlyImportApps
? []
: [
{
name: settings.name,
size: settings.screenSize,
config,
},
],
preparedBoards: settings.onlyImportApps ? [] : validAnalyseConfigs,
};
};

View File

@@ -1,8 +1,7 @@
import { z } from "zod";
import { zfd } from "zod-form-data";
import { boardSizes } from "@homarr/old-schema";
import { validation, zodEnumFromArray } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { createCustomErrorParams } from "@homarr/validation/form";
export const sidebarBehaviours = ["remove-items", "last-section"] as const;
@@ -12,7 +11,6 @@ export type SidebarBehaviour = (typeof sidebarBehaviours)[number];
export const oldmarrImportConfigurationSchema = z.object({
name: validation.board.name,
onlyImportApps: z.boolean().default(false),
screenSize: zodEnumFromArray(boardSizes).default("lg"),
sidebarBehaviour: z.enum(sidebarBehaviours).default(defaultSidebarBehaviour),
});