Replace entire codebase with homarr-labs/homarr

This commit is contained in:
Thomas Camlong
2026-01-15 21:54:44 +01:00
parent c5bc3b1559
commit 4fdd1fe351
4666 changed files with 409577 additions and 147434 deletions

View File

@@ -0,0 +1,72 @@
import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { boardGroupPermissions, boardUserPermissions, groupMembers } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
/**
* Throws NOT_FOUND if user is not allowed to perform action on board
* @param ctx trpc router context
* @param boardWhere where clause for the board
* @param permission permission required to perform action on board
*/
export const throwIfActionForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
boardWhere: SQL<unknown>,
permission: BoardPermission,
) => {
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const board = await db.query.boards.findFirst({
where: boardWhere,
columns: {
id: true,
creatorId: true,
isPublic: true,
},
with: {
userPermissions: {
where: eq(boardUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
},
},
});
if (!board) {
notAllowed();
}
const { hasViewAccess, hasChangeAccess, hasFullAccess } = constructBoardPermissions(board, session);
if (hasFullAccess) {
return; // As full access is required and user has full access, allow
}
if (["modify", "view"].includes(permission) && hasChangeAccess) {
return; // As change access is required and user has change access, allow
}
if (permission === "view" && hasViewAccess) {
return; // As view access is required and user has view access, allow
}
notAllowed();
};
/**
* This method returns NOT_FOUND to prevent snooping on board existence
* A function is used to use the method without return statement
*/
function notAllowed(): never {
throw new TRPCError({
code: "NOT_FOUND",
message: "Board not found",
});
}

View File

@@ -0,0 +1,186 @@
export interface GridAlgorithmItem {
id: string;
type: "item" | "section";
width: number;
height: number;
xOffset: number;
yOffset: number;
sectionId: string;
}
interface GridAlgorithmInput {
items: GridAlgorithmItem[];
width: number;
previousWidth: number;
sectionId: string;
}
interface GridAlgorithmOutput {
height: number;
items: GridAlgorithmItem[];
}
export const generateResponsiveGridFor = ({
items,
previousWidth,
width,
sectionId,
}: GridAlgorithmInput): GridAlgorithmOutput => {
const itemsOfCurrentSection = items
.filter((item) => item.sectionId === sectionId)
.sort((itemA, itemB) =>
itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
);
const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
if (itemsOfCurrentSection.length === 0) {
return {
height: 0,
items: [],
};
}
const newItems: GridAlgorithmItem[] = [];
// Fix height of dynamic sections
const dynamicSectionHeightMap = new Map<string, number>();
const dynamicSectionsOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
for (const dynamicSection of dynamicSectionsOfCurrentSection) {
const result = generateResponsiveGridFor({
items,
previousWidth: dynamicSection.previousWidth,
width: dynamicSection.width,
sectionId: dynamicSection.id,
});
newItems.push(...result.items);
dynamicSectionHeightMap.set(dynamicSection.id, result.height);
}
// Return same positions for items in the current section
if (width >= previousWidth) {
return {
height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
items: newItems.concat(normalizedItems),
};
}
const occupied2d: boolean[][] = [];
for (const item of normalizedItems) {
const itemWithHeight = {
...item,
height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height,
};
const position = nextFreeSpot(occupied2d, itemWithHeight, width);
if (!position) throw new Error("No free spot available");
addItemToOccupied(occupied2d, itemWithHeight, position, width);
newItems.push({
...itemWithHeight,
xOffset: position.x,
yOffset: position.y,
});
}
return {
height: occupied2d.length,
items: newItems,
};
};
/**
* Reduces the width of the items to fit the new column count.
* @param items items to normalize
* @param columnCount new column count
*/
const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) => {
return items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
};
/**
* Adds the item to the occupied spots.
* @param occupied2d array of occupied spots
* @param item item to place
* @param position position to place the item
*/
const addItemToOccupied = (
occupied2d: boolean[][],
item: GridAlgorithmItem,
position: { x: number; y: number },
columnCount: number,
) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
let row = occupied2d[position.y + yOffset];
if (!row) {
addRow(occupied2d, columnCount);
// After adding it, it must exist
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
row = occupied2d[position.y + yOffset]!;
}
for (let xOffset = 0; xOffset < item.width; xOffset++) {
row[position.x + xOffset] = true;
}
}
};
/**
* Adds a new row to the grid.
* @param occupied2d array of occupied spots
* @param columnCount column count of section
*/
const addRow = (occupied2d: boolean[][], columnCount: number) => {
occupied2d.push(new Array<boolean>(columnCount).fill(false));
};
/**
* Searches for the next free spot in the grid.
* @param occupied2d array of occupied spots
* @param item item to place
* @param columnCount column count of section
* @returns the position of the next free spot or null if no spot is available
*/
const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
for (let offsetY = 0; offsetY < 99999; offsetY++) {
for (let offsetX = 0; offsetX < columnCount; offsetX++) {
if (hasHorizontalSpace(columnCount, item, offsetX) && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
return { x: offsetX, y: offsetY };
}
}
}
return null;
};
/**
* Check if the item fits into the grid horizontally.
* @param columnCount available width
* @param item item to place
* @param offsetX current x position
* @returns true if the item fits horizontally
*/
const hasHorizontalSpace = (columnCount: number, item: GridAlgorithmItem, offsetX: number) => {
return offsetX + item.width <= columnCount;
};
/**
* Check if the spot is free.
* @param occupied2d array of occupied spots
* @param item item to place
* @param position position to check
* @returns true if the spot is free
*/
const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
const row = occupied2d[position.y + yOffset];
if (!row) return true; // Empty row is free
for (let xOffset = 0; xOffset < item.width; xOffset++) {
if (row[position.x + xOffset]) {
return false;
}
}
}
return true;
};

View File

@@ -0,0 +1,378 @@
import { createId } from "@paralleldrive/cuid2";
import { describe, expect, test } from "vitest";
import type { GridAlgorithmItem } from "../grid-algorithm";
import { generateResponsiveGridFor } from "../grid-algorithm";
const ROOT_SECTION_ID = "section";
/**
* If you want to see how the layouts progress between the different layouts, you can find images here:
* https://github.com/homarr-labs/architecture-documentation/tree/main/grid-algorithm#graphical-representation-of-the-algorithm
*/
describe("Grid Algorithm", () => {
test.each(itemTests)("should convert a grid with %i columns to a grid with %i columns", (_, _ignored, item) => {
const input = generateInputFromText(item.input);
const result = generateResponsiveGridFor({
items: input,
width: item.outputColumnCount,
previousWidth: item.inputColumnCount,
sectionId: ROOT_SECTION_ID,
});
const output = generateOutputText(result.items, item.outputColumnCount);
expect(output).toBe(item.output);
});
test.each(dynamicSectionTests)(
"should convert a grid with dynamic sections from 16 columns to %i columns",
(_, testInput) => {
const outerDynamicSectionId = "b";
const innerDynamicSectionId = "f";
const items = [
algoItem({ id: "a", width: 2, height: 2 }),
algoItem({ id: outerDynamicSectionId, type: "section", width: 12, height: 3, yOffset: 2 }),
algoItem({ id: "a", width: 2, sectionId: outerDynamicSectionId }),
algoItem({ id: "b", width: 4, sectionId: outerDynamicSectionId, xOffset: 2 }),
algoItem({ id: "c", width: 2, sectionId: outerDynamicSectionId, xOffset: 6 }),
algoItem({ id: "d", width: 1, sectionId: outerDynamicSectionId, xOffset: 8 }),
algoItem({ id: "e", width: 3, sectionId: outerDynamicSectionId, xOffset: 9 }),
algoItem({
id: innerDynamicSectionId,
type: "section",
width: 8,
height: 2,
yOffset: 1,
sectionId: outerDynamicSectionId,
}),
algoItem({ id: "a", width: 2, sectionId: innerDynamicSectionId }),
algoItem({ id: "b", width: 5, xOffset: 2, sectionId: innerDynamicSectionId }),
algoItem({ id: "c", width: 1, height: 2, xOffset: 7, sectionId: innerDynamicSectionId }),
algoItem({ id: "d", width: 7, yOffset: 1, sectionId: innerDynamicSectionId }),
algoItem({ id: "g", width: 4, yOffset: 1, sectionId: outerDynamicSectionId, xOffset: 8 }),
algoItem({ id: "h", width: 3, yOffset: 2, sectionId: outerDynamicSectionId, xOffset: 8 }),
algoItem({ id: "i", width: 1, yOffset: 2, sectionId: outerDynamicSectionId, xOffset: 11 }),
algoItem({ id: "c", width: 5, yOffset: 5 }),
];
const newItems = generateResponsiveGridFor({
items,
width: testInput.outputColumns,
previousWidth: 16,
sectionId: ROOT_SECTION_ID,
});
const rootItems = newItems.items.filter((item) => item.sectionId === ROOT_SECTION_ID);
const outerSection = items.find((item) => item.id === outerDynamicSectionId);
const outerItems = newItems.items.filter((item) => item.sectionId === outerDynamicSectionId);
const innerSection = items.find((item) => item.id === innerDynamicSectionId);
const innerItems = newItems.items.filter((item) => item.sectionId === innerDynamicSectionId);
expect(generateOutputText(rootItems, testInput.outputColumns)).toBe(testInput.root);
expect(generateOutputText(outerItems, Math.min(testInput.outputColumns, outerSection?.width ?? 999))).toBe(
testInput.outer,
);
expect(generateOutputText(innerItems, Math.min(testInput.outputColumns, innerSection?.width ?? 999))).toBe(
testInput.inner,
);
},
);
});
const algoItem = (item: Partial<GridAlgorithmItem>): GridAlgorithmItem => ({
id: createId(),
type: "item",
width: 1,
height: 1,
xOffset: 0,
yOffset: 0,
sectionId: ROOT_SECTION_ID,
...item,
});
const sixteenColumns = `
abbccccddddeeefg
hbbccccddddeeeij
klllmmmmmnneeeop
qlllmmmmmnnrrrst
ulllmmmmmnnrrrvw
xyz äö`;
// Just add two empty columns to the right
const eighteenColumns = sixteenColumns
.split("\n")
.map((line, index) => (index === 0 ? line : `${line} `))
.join("\n");
const tenColumns = `
abbcccceee
fbbcccceee
ddddghieee
ddddjklllo
mmmmmplllq
mmmmmslllt
mmmmmnnrrr
uvwxynnrrr
zäö nn `;
const sixColumns = `
abbfgh
ibbjko
ccccnn
ccccnn
ddddnn
ddddpq
eeelll
eeelll
eeelll
mmmmms
mmmmmt
mmmmmu
rrrvwx
rrryzä
ö `;
const threeColumns = `
abb
fbb
ccc
ccc
ddd
ddd
eee
eee
eee
ghi
jko
lll
lll
lll
mmm
mmm
mmm
nnp
nnq
nns
rrr
rrr
tuv
wxy
zäö`;
const itemTests = [
{
input: sixteenColumns,
inputColumnCount: 16,
output: sixteenColumns,
outputColumnCount: 16,
},
{
input: sixteenColumns,
inputColumnCount: 16,
output: eighteenColumns,
outputColumnCount: 18,
},
{
input: sixteenColumns,
inputColumnCount: 16,
output: tenColumns,
outputColumnCount: 10,
},
{
input: sixteenColumns,
inputColumnCount: 16,
output: sixColumns,
outputColumnCount: 6,
},
{
input: sixteenColumns,
inputColumnCount: 16,
output: threeColumns,
outputColumnCount: 3,
},
].map((item) => [item.inputColumnCount, item.outputColumnCount, item] as const);
const dynamicSectionTests = [
{
outputColumns: 16,
root: `
aa
aa
bbbbbbbbbbbb
bbbbbbbbbbbb
bbbbbbbbbbbb
ccccc `,
outer: `
aabbbbccdeee
ffffffffgggg
ffffffffhhhi`,
inner: `
aabbbbbc
dddddddc`,
},
{
outputColumns: 10,
root: `
aaccccc
aa
bbbbbbbbbb
bbbbbbbbbb
bbbbbbbbbb
bbbbbbbbbb`,
outer: `
aabbbbccdi
eeegggghhh
ffffffff
ffffffff `,
inner: `
aabbbbbc
dddddddc`,
},
{
outputColumns: 6,
root: `
aa
aa
bbbbbb
bbbbbb
bbbbbb
bbbbbb
bbbbbb
bbbbbb
bbbbbb
ccccc `,
outer: `
aabbbb
ccdeee
ffffff
ffffff
ffffff
ggggi
hhh `,
inner: `
aa c
bbbbbc
dddddd`,
},
{
outputColumns: 3,
root: `
aa
aa
bbb
bbb
bbb
bbb
bbb
bbb
bbb
bbb
bbb
bbb
bbb
ccc`,
outer: `
aad
bbb
cci
eee
fff
fff
fff
fff
fff
ggg
hhh`,
inner: `
aa
bbb
c
c
ddd`,
},
].map((item) => [item.outputColumns, item] as const);
const generateInputFromText = (text: string) => {
const lines = text.split("\n").slice(1); // Remove first empty row
const items: GridAlgorithmItem[] = [];
for (let yOffset = 0; yOffset < lines.length; yOffset++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const line = lines[yOffset]!;
for (let xOffset = 0; xOffset < line.length; xOffset++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const char = line[xOffset]!;
if (char === " ") continue;
if (items.some((item) => item.id === char)) continue;
items.push({
id: char,
type: "item",
width: getWidth(line, xOffset, char),
height: getHeight(lines, { x: xOffset, y: yOffset }, char),
xOffset,
yOffset,
sectionId: ROOT_SECTION_ID,
});
}
}
return items;
};
const generateOutputText = (items: GridAlgorithmItem[], columnCount: number) => {
const occupied2d: string[][] = [];
for (const item of items) {
addItemToOccupied(occupied2d, item, { x: item.xOffset, y: item.yOffset }, columnCount);
}
return `\n${occupied2d.map((row) => row.join("")).join("\n")}`;
};
const getWidth = (line: string, offset: number, char: string) => {
const row = line.split("");
let width = 1;
for (let xOffset = offset + 1; xOffset < row.length; xOffset++) {
if (row[xOffset] === char) {
width++;
} else {
break;
}
}
return width;
};
const getHeight = (lines: string[], position: { x: number; y: number }, char: string) => {
let height = 1;
for (let yOffset = position.y + 1; yOffset < lines.length; yOffset++) {
if (lines[yOffset]?.[position.x] === char) {
height++;
} else {
break;
}
}
return height;
};
const addItemToOccupied = (
occupied2d: string[][],
item: GridAlgorithmItem,
position: { x: number; y: number },
columnCount: number,
) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
let row = occupied2d[position.y + yOffset];
if (!row) {
addRow(occupied2d, columnCount);
// After adding it, it must exist
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
row = occupied2d[position.y + yOffset]!;
}
for (let xOffset = 0; xOffset < item.width; xOffset++) {
row[position.x + xOffset] = item.id;
}
}
};
const addRow = (occupied2d: string[][], columnCount: number) => {
occupied2d.push(new Array<string>(columnCount).fill(" "));
};