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,30 @@
"use client";
import { Button } from "@mantine/core";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import type { SortableItemListInput } from "../options";
import { AppSelectModal } from "./app-select-modal";
export const BookmarkAddButton: SortableItemListInput<
{
name: string;
description: string | null;
id: string;
iconUrl: string;
href: string | null;
pingUrl: string | null;
},
string
>["AddButton"] = ({ addItem, values }) => {
const { openModal } = useModalAction(AppSelectModal);
const t = useI18n();
return (
<Button onClick={() => openModal({ onSelect: addItem, presentAppIds: values })}>
{t("widget.bookmarks.option.items.add")}
</Button>
);
};

View File

@@ -0,0 +1,114 @@
"use client";
import { memo, useState } from "react";
import type { SelectProps } from "@mantine/core";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
presentAppIds: string[];
onSelect: (props: RouterOutputs["app"]["selectable"][number]) => void | Promise<void>;
confirmLabel?: string;
}
interface AppSelectFormType {
id: string;
}
export const AppSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const { data: apps, isPending } = clientApi.app.selectable.useQuery();
const [loading, setLoading] = useState(false);
const form = useForm<AppSelectFormType>();
const handleSubmitAsync = async (values: AppSelectFormType) => {
const currentApp = apps?.find((app) => app.id === values.id);
if (!currentApp) return;
setLoading(true);
await innerProps.onSelect(currentApp);
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
const currentApp = apps?.find((app) => app.id === form.values.id);
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("id")}
label={t("app.action.select.label")}
searchable
clearable
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
nothingFoundMessage={t("app.action.select.notFound")}
renderOption={renderSelectOption}
limit={5}
data={
apps
?.filter((app) => !innerProps.presentAppIds.includes(app.id))
.map((app) => ({
label: app.name,
value: app.id,
iconUrl: app.iconUrl,
})) ?? []
}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("app.action.select.label"),
});
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: 18,
};
const renderSelectOption: SelectProps["renderOption"] = ({ option, checked }) => (
<Group flex="1" gap="xs">
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
<img width={20} height={20} src={option.iconUrl} alt={option.label} />
) : null}
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
interface LeftSectionProps {
isPending: boolean;
currentApp: RouterOutputs["app"]["selectable"][number] | undefined;
}
const size = 20;
const LeftSection = ({ isPending, currentApp }: LeftSectionProps) => {
if (isPending) {
return <Loader size={size} />;
}
if (currentApp) {
return <img width={size} height={size} src={currentApp.iconUrl} alt={currentApp.name} />;
}
return null;
};
const MemoizedLeftSection = memo(LeftSection);

View File

@@ -0,0 +1,15 @@
.card:hover {
background-color: var(--mantine-color-primaryColor-light-hover);
}
[data-mantine-color-scheme="light"] .card-grid {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme="dark"] .card-grid {
background-color: var(--mantine-color-dark-7);
}
.card:hover > div > div.bookmarkIcon {
background-color: var(--mantine-color-iconColor-filled-hover);
}

View File

@@ -0,0 +1,290 @@
"use client";
import { Anchor, Card, Flex, Group, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import combineClasses from "clsx";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import classes from "./bookmark.module.css";
export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard();
const [data] = clientApi.app.byIds.useSuspenseQuery(options.items, {
select(data) {
return data.sort((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
});
useRegisterSpotlightContextResults(
`bookmark-${itemId}`,
data
.filter((app) => app.href !== null)
.map((app) => ({
id: app.id,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link",
// We checked above that app.href is defined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: app.href!,
newTab: false,
};
},
})),
[data],
);
return (
<Stack h="100%" gap="sm" p="sm">
{options.title.length > 0 && (
<Title order={4} px="0.25rem">
{options.title}
</Title>
)}
{(options.layout === "grid" || options.layout === "gridHorizontal") && (
<GridLayout
data={data}
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
hideTitle={options.hideTitle}
hideIcon={options.hideIcon}
hideHostname={options.hideHostname}
openNewTab={options.openNewTab}
hasIconColor={board.iconColor !== null}
/>
)}
{options.layout !== "grid" && options.layout !== "gridHorizontal" && (
<FlexLayout
data={data}
direction={options.layout}
hideTitle={options.hideTitle}
hideIcon={options.hideIcon}
hideHostname={options.hideHostname}
openNewTab={options.openNewTab}
hasIconColor={board.iconColor !== null}
/>
)}
</Stack>
);
}
interface FlexLayoutProps {
data: RouterOutputs["app"]["byIds"];
direction: "row" | "column";
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
openNewTab: boolean;
hasIconColor: boolean;
}
const FlexLayout = ({
data,
direction,
hideTitle,
hideIcon,
hideHostname,
openNewTab,
hasIconColor,
}: FlexLayoutProps) => {
const board = useRequiredBoard();
return (
<Flex direction={direction} gap="0" w="100%">
{data.map((app) => (
<div key={app.id} style={{ display: "flex", flex: "1", flexDirection: direction }}>
<UnstyledButton
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
rel="noopener noreferrer"
key={app.id}
w="100%"
>
<Card radius={board.itemRadius} className={classes.card} w="100%" display="flex" p={4} h="100%">
{direction === "row" ? (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
</div>
))}
</Flex>
);
};
interface GridLayoutProps {
data: RouterOutputs["app"]["byIds"];
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
openNewTab: boolean;
itemDirection: "horizontal" | "vertical";
hasIconColor: boolean;
}
const GridLayout = ({
data,
hideTitle,
hideIcon,
hideHostname,
openNewTab,
itemDirection,
hasIconColor,
}: GridLayoutProps) => {
const board = useRequiredBoard();
return (
<Flex miw="100%" gap={4} wrap="wrap" style={{ flex: 1 }}>
{data.map((app) => (
<UnstyledButton
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
rel="noopener noreferrer"
key={app.id}
flex="1"
>
<Card
h="100%"
className={combineClasses(classes.card, classes["card-grid"])}
radius={board.itemRadius}
p="xs"
>
{itemDirection === "horizontal" ? (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
))}
</Flex>
);
};
const VerticalItem = ({
app,
hideTitle,
hideIcon,
hideHostname,
hasIconColor,
}: {
app: RouterOutputs["app"]["byIds"][number];
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
hasIconColor: boolean;
}) => {
return (
<Stack h="100%" miw={16} gap="sm" justify={"center"}>
{!hideTitle && (
<Text fw={700} ta="center" size="xs">
{app.name}
</Text>
)}
{!hideIcon && (
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={hasIconColor}
alt={app.name}
className={classes.bookmarkIcon}
style={{
width: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
height: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
overflow: "auto",
flex: "unset",
marginLeft: "auto",
marginRight: "auto",
}}
/>
)}
{!hideHostname && (
<Anchor ta="center" component="span" size="xs">
{app.href ? new URL(app.href).hostname : undefined}
</Anchor>
)}
</Stack>
);
};
const HorizontalItem = ({
app,
hideTitle,
hideIcon,
hideHostname,
hasIconColor,
}: {
app: RouterOutputs["app"]["byIds"][number];
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
hasIconColor: boolean;
}) => {
return (
<Group wrap="nowrap" gap="xs" h="100%" justify="start">
{!hideIcon && (
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={hasIconColor}
alt={app.name}
className={classes.bookmarkIcon}
style={{
overflow: "auto",
width: hideHostname ? 16 : 24,
height: hideHostname ? 16 : 24,
flex: "unset",
}}
/>
)}
{!(hideTitle && hideHostname) && (
<>
<Stack justify="space-between" gap={0}>
{!hideTitle && (
<Text fw={700} size="xs" lineClamp={hideHostname ? 2 : 1}>
{app.name}
</Text>
)}
{!hideHostname && (
<Anchor component="span" size="xs">
{app.href ? new URL(app.href).hostname : undefined}
</Anchor>
)}
</Stack>
</>
)}
</Group>
);
};

View File

@@ -0,0 +1,62 @@
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
import { IconBookmark, IconX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { BookmarkAddButton } from "./add-button";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconBookmark,
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text(),
layout: factory.select({
options: (["grid", "gridHorizontal", "row", "column"] as const).map((value) => ({
value,
label: (t) => t(`widget.bookmarks.option.layout.option.${value}.label`),
})),
defaultValue: "column",
}),
hideTitle: factory.switch({ defaultValue: false }),
hideIcon: factory.switch({ defaultValue: false }),
hideHostname: factory.switch({ defaultValue: false }),
openNewTab: factory.switch({ defaultValue: true }),
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => {
return (
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Handle />
<Group>
<Avatar src={item.iconUrl} alt={item.name} />
<Stack gap={0}>
<Text>{item.name}</Text>
</Stack>
</Group>
</Group>
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
<IconX size={20} />
</ActionIcon>
</Group>
);
},
AddButton: BookmarkAddButton,
uniqueIdentifier: (item) => item.id,
useData: (initialIds) => {
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
return {
data,
error,
isLoading,
};
},
}),
}));
},
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,32 @@
import { trpc } from "@homarr/api/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db, inArray } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import type { Prefetch } from "../definition";
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
const appIds = items.flatMap((item) => item.options.items);
const distinctAppIds = [...new Set(appIds)];
const dbApps = await db.query.apps.findMany({
where: inArray(apps.id, distinctAppIds),
});
for (const item of items) {
if (item.options.items.length === 0) {
continue;
}
queryClient.setQueryData(
trpc.app.byIds.queryKey(item.options.items),
dbApps.filter((app) => item.options.items.includes(app.id)),
);
}
logger.info("Successfully prefetched apps for bookmarks", { count: dbApps.length });
};
export default prefetchAllAsync;