Compare commits

..
Author SHA1 Message Date
Manuel 168a951ad9 feat: notification system 2024-12-05 18:12:07 +01:00
51 changed files with 384 additions and 1342 deletions

No files matched your search

-5
View File
@@ -14,11 +14,6 @@
"gridstack",
"homarr",
"jellyfin",
"llen",
"lpop",
"lpush",
"lrange",
"ltrim",
"mantine",
"manuel-rw",
"Meierschlumpf",
+4 -4
View File
@@ -45,9 +45,9 @@
"@million/lint": "1.0.13",
"@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.24.0",
"@tanstack/react-query": "^5.62.3",
"@tanstack/react-query-devtools": "^5.62.3",
"@tanstack/react-query-next-experimental": "5.62.3",
"@tanstack/react-query": "^5.62.2",
"@tanstack/react-query-devtools": "^5.62.2",
"@tanstack/react-query-next-experimental": "5.62.2",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -71,7 +71,7 @@
"react-error-boundary": "^4.1.2",
"react-simple-code-editor": "^0.14.1",
"sass": "^1.82.0",
"superjson": "2.2.2",
"superjson": "2.2.1",
"swagger-ui-react": "^5.18.2",
"use-deep-compare-effect": "^1.8.1"
},
-2
View File
@@ -13,7 +13,6 @@ import { env } from "@homarr/auth/env.mjs";
import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
import { SpotlightProvider } from "@homarr/spotlight";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { getI18nMessages } from "@homarr/translation/server";
@@ -83,7 +82,6 @@ export default async function Layout(props: { children: React.ReactNode; params:
(innerProps) => <NextIntlClientProvider {...innerProps} messages={i18nMessages} />,
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
(innerProps) => <ModalProvider {...innerProps} />,
(innerProps) => <SpotlightProvider {...innerProps} />,
]);
return (
@@ -1,21 +1,13 @@
import { Indicator, UnstyledButton } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { UnstyledButton } from "@mantine/core";
import { CurrentUserAvatar } from "~/components/user-avatar";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
export const UserButton = async () => {
const data = await api.updateChecker.getAvailableUpdates();
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin");
export const UserButton = () => {
return (
<UserAvatarMenu availableUpdates={isAdmin ? data : undefined}>
<UserAvatarMenu>
<UnstyledButton>
<Indicator disabled={!data || data.length === 0 || !isAdmin} size={15} processing withBorder>
<CurrentUserAvatar size="md" />
</Indicator>
<CurrentUserAvatar size="md" />
</UnstyledButton>
</UserAvatarMenu>
);
@@ -7,7 +7,6 @@ import { useRouter } from "next/navigation";
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { useHotkeys, useTimeout } from "@mantine/hooks";
import {
IconBellRinging,
IconCheck,
IconHome,
IconLogin,
@@ -24,17 +23,14 @@ import { useScopedI18n } from "@homarr/translation/client";
import "flag-icons/css/flag-icons.min.css";
import type { RouterOutputs } from "@homarr/api";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
interface UserAvatarMenuProps {
children: ReactNode;
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"];
}
export const UserAvatarMenu = ({ children, availableUpdates }: UserAvatarMenuProps) => {
export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
useHotkeys([["mod+J", toggleColorScheme]]);
@@ -68,21 +64,6 @@ export const UserAvatarMenu = ({ children, availableUpdates }: UserAvatarMenuPro
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withArrow withinPortal keepMounted>
<Menu.Dropdown>
{availableUpdates && availableUpdates.length > 0 && availableUpdates[0] && (
<>
<Menu.Item
component={"a"}
href={availableUpdates[0].url}
target="_blank"
leftSection={<IconBellRinging size="1rem" />}
>
<Text fw="bold" size="sm">
{t("updateAvailable", { countUpdates: availableUpdates.length, tag: availableUpdates[0].tag_name })}
</Text>
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.Item onClick={toggleColorScheme} leftSection={<ColorSchemeIcon size="1rem" />}>
{colorSchemeText}
</Menu.Item>
+1 -1
View File
@@ -37,7 +37,7 @@
"@homarr/widgets": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"superjson": "2.2.2",
"superjson": "2.2.1",
"undici": "7.1.0"
},
"devDependencies": {
+3 -5
View File
@@ -1,7 +1,6 @@
{
"name": "homarr",
"private": true,
"version": "1.0.0",
"scripts": {
"build": "cross-env CI=true turbo build",
"clean": "git clean -xdf node_modules",
@@ -40,17 +39,16 @@
"testcontainers": "^10.16.0",
"turbo": "^2.3.3",
"typescript": "^5.7.2",
"vite-tsconfig-paths": "^5.1.4",
"vite-tsconfig-paths": "^5.1.3",
"vitest": "^2.1.8"
},
"packageManager": "pnpm@9.15.0",
"packageManager": "pnpm@9.14.4",
"engines": {
"node": ">=22.12.0"
},
"pnpm": {
"patchedDependencies": {
"pretty-print-error": "patches/pretty-print-error.patch"
},
"allowNonAppliedPatches": true
}
}
}
+1 -1
View File
@@ -26,7 +26,7 @@
"@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@umami/node": "^0.4.0",
"superjson": "2.2.2"
"superjson": "2.2.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
+1 -1
View File
@@ -42,7 +42,7 @@
"dockerode": "^4.0.2",
"next": "^14.2.20",
"react": "^18.3.1",
"superjson": "2.2.2",
"superjson": "2.2.1",
"trpc-to-openapi": "^2.1.0"
},
"devDependencies": {
-2
View File
@@ -13,7 +13,6 @@ import { logRouter } from "./router/log";
import { mediaRouter } from "./router/medias/media-router";
import { searchEngineRouter } from "./router/search-engine/search-engine-router";
import { serverSettingsRouter } from "./router/serverSettings";
import { updateCheckerRouter } from "./router/update-checker";
import { userRouter } from "./router/user";
import { widgetRouter } from "./router/widgets";
import { createTRPCRouter } from "./trpc";
@@ -36,7 +35,6 @@ export const appRouter = createTRPCRouter({
cronJobs: cronJobsRouter,
apiKeys: apiKeysRouter,
media: mediaRouter,
updateChecker: updateCheckerRouter,
});
// export type definition of API
-11
View File
@@ -1,11 +0,0 @@
import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const updateCheckerRouter = createTRPCRouter({
getAvailableUpdates: protectedProcedure.query(async () => {
const handler = updateCheckerRequestHandler.handler({});
const data = await handler.getCachedOrUpdatedDataAsync({});
return data.data.availableUpdates;
}),
});
@@ -1,6 +1,5 @@
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { HealthMonitoring } from "@homarr/integrations";
import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
@@ -9,7 +8,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const healthMonitoringRouter = createTRPCRouter({
getHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("healthMonitoring")))
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault"))
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
@@ -27,7 +26,7 @@ export const healthMonitoringRouter = createTRPCRouter({
}),
subscribeHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("healthMonitoring")))
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault"))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
const unsubscribes: (() => void)[] = [];
+1 -1
View File
@@ -29,7 +29,7 @@
"dayjs": "^1.11.13",
"next": "^14.2.20",
"react": "^18.3.1",
"tldts": "^6.1.66"
"tldts": "^6.1.65"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
-1
View File
@@ -1,6 +1,5 @@
import { parseAppHrefWithVariables } from "./base";
export const parseAppHrefWithVariablesClient = <TInput extends string | null>(url: TInput): TInput => {
if (typeof window === "undefined") return url;
return parseAppHrefWithVariables(url, window.location.href);
};
+1 -3
View File
@@ -38,9 +38,7 @@
"@homarr/request-handler": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"octokit": "^4.0.2",
"semver-parser": "^4.1.7"
"@homarr/validation": "workspace:^0.1.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
+2 -2
View File
@@ -12,8 +12,8 @@ import { pingJob } from "./jobs/ping";
import type { RssFeed } from "./jobs/rss-feeds";
import { rssFeedsJob } from "./jobs/rss-feeds";
import { sessionCleanupJob } from "./jobs/session-cleanup";
import { updateCheckerJob } from "./jobs/update-checker";
import { createCronJobGroup } from "./lib";
import {notificationsFetcherJob} from "./jobs/notifications-fetcher";
export const jobGroup = createCronJobGroup({
analytics: analyticsJob,
@@ -30,7 +30,7 @@ export const jobGroup = createCronJobGroup({
indexerManager: indexerManagerJob,
healthMonitoring: healthMonitoringJob,
sessionCleanup: sessionCleanupJob,
updateChecker: updateCheckerJob,
"notifications-fetcher": notificationsFetcherJob
});
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
@@ -0,0 +1,8 @@
import {createCronJob} from "../lib";
import {EVERY_HOUR} from "@homarr/cron-jobs-core/expressions";
export const notificationsFetcherJob = createCronJob("notifications-fetcher", EVERY_HOUR, {
runOnStart: true
}).withCallback(async () => {
});
@@ -1,13 +0,0 @@
import { EVERY_HOUR } from "@homarr/cron-jobs-core/expressions";
import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
import { createCronJob } from "../lib";
export const updateCheckerJob = createCronJob("updateChecker", EVERY_HOUR, {
runOnStart: true,
}).withCallback(async () => {
const handler = updateCheckerRequestHandler.handler({});
await handler.getCachedOrUpdatedDataAsync({
forceUpdate: true,
});
});
+14
View File
@@ -354,6 +354,20 @@ export const icons = sqliteTable("icon", {
.references(() => iconRepositories.id, { onDelete: "cascade" }),
});
export const notifications = sqliteTable("notifications", {
id: text().notNull().primaryKey(),
createdAt: int({ mode: "timestamp" }).notNull(),
dismissAfterOneAcknowledgement: int({ mode: "boolean" }).default(false).notNull(),
content: text().notNull()
});
export const notificationsAcknowledgements = sqliteTable("notificationAcknowledgements", {
id: text().notNull().primaryKey(),
acknowledgedAt: int({ mode: "timestamp" }).notNull(),
userId: text().notNull().references(() => users.id, { onDelete: "cascade" }),
notificationId: text().notNull().references(() => notifications.id, { onDelete: "cascade" }),
});
export const iconRepositories = sqliteTable("iconRepository", {
id: text().notNull().primaryKey(),
slug: text().notNull(),
-7
View File
@@ -144,13 +144,6 @@ export const integrationDefs = {
category: ["healthMonitoring"],
supportsSearch: false,
},
dashDot: {
name: "Dash.",
secretKinds: [[]],
category: ["healthMonitoring"],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/dashdot.png",
supportsSearch: false,
},
} as const satisfies Record<string, integrationDefinition>;
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
@@ -4,7 +4,6 @@ import type { Integration as DbIntegration } from "@homarr/db/schema/sqlite";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration";
@@ -69,5 +68,4 @@ export const integrationCreators = {
openmediavault: OpenMediaVaultIntegration,
lidarr: LidarrIntegration,
readarr: ReadarrIntegration,
dashDot: DashDotIntegration,
} satisfies Record<IntegrationKind, new (integration: IntegrationInput) => Integration>;
@@ -1,148 +0,0 @@
import { humanFileSize } from "@homarr/common";
import "@homarr/redis";
import dayjs from "dayjs";
import { z } from "@homarr/validation";
import { createChannelEventHistory } from "../../../redis/src/lib/channel";
import { Integration } from "../base/integration";
import type { HealthMonitoring } from "../types";
export class DashDotIntegration extends Integration {
public async testConnectionAsync(): Promise<void> {
const response = await fetch(this.url("/info"));
await response.json();
}
public async getSystemInfoAsync(): Promise<HealthMonitoring> {
const info = await this.getInfoAsync();
const cpuLoad = await this.getCurrentCpuLoadAsync();
const memoryLoad = await this.getCurrentMemoryLoadAsync();
const storageLoad = await this.getCurrentStorageLoadAsync();
const channel = this.getChannel();
const history = await channel.getSliceUntilTimeAsync(dayjs().subtract(15, "minutes").toDate());
return {
cpuUtilization: cpuLoad.sumLoad,
memUsed: `${memoryLoad.loadInBytes}`,
memAvailable: `${info.maxAvailableMemoryBytes - memoryLoad.loadInBytes}`,
fileSystem: info.storage.map((storage, index) => ({
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
used: humanFileSize(storageLoad[index]!),
available: `${storage.size}`,
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
})),
cpuModelName: info.cpuModel === "" ? `Unknown Model (${info.cpuBrand})` : `${info.cpuModel} (${info.cpuBrand})`,
cpuTemp: cpuLoad.averageTemperature,
availablePkgUpdates: 0,
rebootRequired: false,
smart: [],
uptime: info.uptime,
version: `${info.operatingSystemVersion}`,
loadAverage: {
"1min": Math.round(this.getAverageOfCpu(history[0])),
"5min": Math.round(this.getAverageOfCpuFlat(history.slice(0, 4))),
"15min": Math.round(this.getAverageOfCpuFlat(history.slice(0, 14))),
},
};
}
private async getInfoAsync() {
const infoResponse = await fetch(this.url("/info"));
const serverInfo = await internalServerInfoApi.parseAsync(await infoResponse.json());
return {
maxAvailableMemoryBytes: serverInfo.ram.size,
storage: serverInfo.storage,
cpuBrand: serverInfo.cpu.brand,
cpuModel: serverInfo.cpu.model,
operatingSystemVersion: `${serverInfo.os.distro} ${serverInfo.os.release} (${serverInfo.os.kernel})`,
uptime: serverInfo.os.uptime,
};
}
private async getCurrentCpuLoadAsync() {
const channel = this.getChannel();
const cpu = await fetch(this.url("/load/cpu"));
const data = await cpuLoadPerCoreApiList.parseAsync(await cpu.json());
await channel.pushAsync(data);
return {
sumLoad: this.getAverageOfCpu(data),
averageTemperature: data.reduce((acc, current) => acc + current.temp, 0) / data.length,
};
}
private getAverageOfCpuFlat(cpuLoad: z.infer<typeof cpuLoadPerCoreApiList>[]) {
const averages = cpuLoad.map((load) => this.getAverageOfCpu(load));
return averages.reduce((acc, current) => acc + current, 0) / averages.length;
}
private getAverageOfCpu(cpuLoad?: z.infer<typeof cpuLoadPerCoreApiList>) {
if (!cpuLoad) {
return 0;
}
return cpuLoad.reduce((acc, current) => acc + current.load, 0) / cpuLoad.length;
}
private async getCurrentStorageLoadAsync() {
const storageLoad = await fetch(this.url("/load/storage"));
return (await storageLoad.json()) as number[];
}
private async getCurrentMemoryLoadAsync() {
const memoryLoad = await fetch(this.url("/load/ram"));
const data = await memoryLoadApi.parseAsync(await memoryLoad.json());
return {
loadInBytes: data.load,
};
}
private getChannel() {
return createChannelEventHistory<z.infer<typeof cpuLoadPerCoreApiList>>(
`integration:${this.integration.id}:history:cpu`,
100,
);
}
}
const cpuLoadPerCoreApi = z.object({
load: z.number().min(0),
temp: z.number().min(0),
});
const memoryLoadApi = z.object({
load: z.number().min(0),
});
const internalServerInfoApi = z.object({
os: z.object({
distro: z.string(),
kernel: z.string(),
release: z.string(),
uptime: z.number().min(0),
}),
cpu: z.object({
brand: z.string(),
model: z.string(),
}),
ram: z.object({
size: z.number().min(0),
}),
storage: z.array(
z.object({
size: z.number().min(0),
disks: z.array(
z.object({
device: z.string(),
brand: z.string(),
type: z.string(),
}),
),
}),
),
});
const cpuLoadPerCoreApiList = z.array(cpuLoadPerCoreApi);
+1 -1
View File
@@ -27,7 +27,7 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"ioredis": "5.4.1",
"superjson": "2.2.2",
"superjson": "2.2.1",
"winston": "3.17.0"
},
"devDependencies": {
+1 -1
View File
@@ -28,7 +28,7 @@
"@homarr/log": "workspace:^0.1.0",
"@homarr/old-schema": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"superjson": "2.2.2"
"superjson": "2.2.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
+1 -1
View File
@@ -27,7 +27,7 @@
"@homarr/definitions": "workspace:^",
"@homarr/log": "workspace:^",
"ioredis": "5.4.1",
"superjson": "2.2.2"
"superjson": "2.2.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
-1
View File
@@ -7,7 +7,6 @@ export {
createIntegrationOptionsChannel,
createChannelWithLatestAndEvents,
handshakeAsync,
createSubPubChannel,
} from "./lib/channel";
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
-61
View File
@@ -187,67 +187,6 @@ export const createItemChannel = <TData>(itemId: string) => {
return createChannelWithLatestAndEvents<TData>(`item:${itemId}`);
};
export const createChannelEventHistory = <TData>(channelName: string, maxElements = 15) => {
const popElementsOverMaxAsync = async () => {
const length = await getSetClient.llen(channelName);
if (length <= maxElements) {
return;
}
await getSetClient.ltrim(channelName, length - maxElements, length);
};
return {
subscribe: (callback: (data: TData) => void) => {
return ChannelSubscriptionTracker.subscribe(channelName, (message) => {
callback(superjson.parse(message));
});
},
publishAndPushAsync: async (data: TData) => {
await publisher.publish(channelName, superjson.stringify(data));
await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
await popElementsOverMaxAsync();
},
pushAsync: async (data: TData) => {
await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
await popElementsOverMaxAsync();
},
clearAsync: async () => {
await getSetClient.del(channelName);
},
getLastAsync: async () => {
const length = await getSetClient.llen(channelName);
const data = await getSetClient.lrange(channelName, length - 1, length);
if (data.length !== 1) return null;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return superjson.parse<{ data: TData; timestamp: Date }>(data[0]!);
},
getSliceAsync: async (startIndex: number, endIndex: number) => {
const range = await getSetClient.lrange(channelName, startIndex, endIndex);
return range.map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item));
},
getSliceUntilTimeAsync: async (time: Date) => {
const length = await getSetClient.llen(channelName);
const items: TData[] = [];
const itemsInCollection = await getSetClient.lrange(channelName, 0, length - 1);
for (let i = 0; i < length - 1; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const deserializedItem = superjson.parse<{ data: TData; timestamp: Date }>(itemsInCollection[i]!);
if (deserializedItem.timestamp < time) {
continue;
}
items.push(deserializedItem.data);
}
return items;
},
getLengthAsync: async () => {
return await getSetClient.llen(channelName);
},
name: channelName,
};
};
export const createChannelWithLatestAndEvents = <TData>(channelName: string) => {
return {
subscribe: (callback: (data: TData) => void) => {
+1 -1
View File
@@ -30,7 +30,7 @@
"@homarr/redis": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"pretty-print-error": "^1.1.2",
"superjson": "2.2.2"
"superjson": "2.2.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
@@ -1,59 +0,0 @@
import dayjs from "dayjs";
import { Octokit } from "octokit";
import { compareSemVer, isValidSemVer } from "semver-parser";
import { logger } from "@homarr/log";
import { createChannelWithLatestAndEvents } from "@homarr/redis";
import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler";
import packageJson from "../../../package.json";
export const updateCheckerRequestHandler = createCachedRequestHandler({
queryKey: "",
cacheDuration: dayjs.duration(1, "hour"),
async requestAsync(_) {
const octokit = new Octokit();
const releases = await octokit.rest.repos.listReleases({
owner: "homarr-labs",
repo: "homarr",
});
const currentVersion = (packageJson as { version: string }).version;
const availableReleases = [];
for (const release of releases.data) {
if (!isValidSemVer(release.tag_name)) {
logger.warn(`Unable to parse semantic tag '${release.tag_name}'. Update check might not work.`);
continue;
}
availableReleases.push(release);
}
const availableNewerReleases = availableReleases
.filter((release) => compareSemVer(release.tag_name, currentVersion) > 0)
.sort((releaseA, releaseB) => compareSemVer(releaseB.tag_name, releaseA.tag_name));
if (availableReleases.length > 0) {
logger.info(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
`Update checker found a new available version: ${availableReleases[0]!.tag_name}. Current version is ${currentVersion}`,
);
} else {
logger.debug(`Update checker did not find any available updates. Current version is ${currentVersion}`);
}
return {
availableUpdates: availableNewerReleases.map((release) => ({
name: release.name,
contentHtml: release.body_html,
url: release.html_url,
tag_name: release.tag_name,
})),
};
},
createRedisChannel() {
return createChannelWithLatestAndEvents<{
availableUpdates: { name: string | null; contentHtml?: string; url: string; tag_name: string }[];
}>("homarr:update");
},
});
@@ -34,10 +34,6 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
});
if (Array.isArray(options)) {
if (options.length === 0) {
return null;
}
const filteredOptions = options
.filter((option) => ("filter" in group ? group.filter(query, option) : false))
.sort((optionA, optionB) => {
+12 -34
View File
@@ -1,7 +1,7 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
import { IconSearch, IconX } from "@tabler/icons-react";
@@ -12,7 +12,6 @@ import { useI18n } from "@homarr/translation/client";
import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode";
import { searchModes } from "../modes";
import { useSpotlightContextResults } from "../modes/home/context";
import { selectAction, spotlightStore } from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group";
@@ -20,45 +19,24 @@ import { SpotlightActionGroups } from "./actions/groups/action-group";
type SearchModeKey = keyof TranslationObject["search"]["mode"];
export const Spotlight = () => {
const items = useSpotlightContextResults();
// We fallback to help if no context results are available
const defaultMode = items.length >= 1 ? "home" : "help";
const searchModeState = useState<SearchModeKey>(defaultMode);
const searchModeState = useState<SearchModeKey>("help");
const mode = searchModeState[0];
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
/**
* The below logic is used to switch to home page if any context results are registered
* or to help page if context results are unregistered
*/
const previousLengthRef = useRef(items.length);
useEffect(() => {
if (items.length >= 1 && previousLengthRef.current === 0) {
searchModeState[1]("home");
} else if (items.length === 0 && previousLengthRef.current >= 1) {
searchModeState[1]("help");
}
previousLengthRef.current = items.length;
}, [items.length, searchModeState]);
if (!activeMode) {
return null;
}
// We use the "key" below to prevent the 'Different amounts of hooks' error
return (
<SpotlightWithActiveMode key={mode} modeState={searchModeState} activeMode={activeMode} defaultMode={defaultMode} />
);
return <SpotlightWithActiveMode key={mode} modeState={searchModeState} activeMode={activeMode} />;
};
interface SpotlightWithActiveModeProps {
modeState: [SearchModeKey, Dispatch<SetStateAction<SearchModeKey>>];
activeMode: SearchMode;
defaultMode: SearchModeKey;
}
const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: SpotlightWithActiveModeProps) => {
const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveModeProps) => {
const [query, setQuery] = useState("");
const [mode, setMode] = modeState;
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
@@ -72,12 +50,12 @@ const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: Spotlig
<MantineSpotlight.Root
yOffset={8}
onSpotlightClose={() => {
setMode(defaultMode);
setMode("help");
setChildrenOptions(null);
}}
query={query}
onQueryChange={(query) => {
if ((mode !== "help" && mode !== "home") || query.length !== 1) {
if (mode !== "help" || query.length !== 1) {
setQuery(query);
}
@@ -95,13 +73,13 @@ const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: Spotlig
<MantineSpotlight.Search
placeholder={`${t("search.placeholder")}...`}
ref={inputRef}
leftSectionWidth={activeMode.modeKey !== defaultMode ? 80 : 48}
leftSectionWidth={activeMode.modeKey !== "help" ? 80 : 48}
leftSection={
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
<Center w={48} h="100%">
<IconSearch stroke={1.5} />
</Center>
{activeMode.modeKey !== defaultMode ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
{activeMode.modeKey !== "help" ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
</Group>
}
styles={{
@@ -110,10 +88,10 @@ const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: Spotlig
},
}}
rightSection={
mode === defaultMode ? undefined : (
mode === "help" ? undefined : (
<ActionIcon
onClick={() => {
setMode(defaultMode);
setMode("help");
setChildrenOptions(null);
inputRef.current?.focus();
}}
@@ -125,8 +103,8 @@ const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: Spotlig
}
value={query}
onKeyDown={(event) => {
if (query.length === 0 && mode !== defaultMode && event.key === "Backspace") {
setMode(defaultMode);
if (query.length === 0 && mode !== "help" && event.key === "Backspace") {
setMode("help");
setChildrenOptions(null);
}
}}
-5
View File
@@ -4,10 +4,5 @@ import { spotlightActions } from "./spotlight-store";
export { Spotlight } from "./components/spotlight";
export { openSpotlight };
export {
SpotlightProvider,
useRegisterSpotlightContextResults,
useRegisterSpotlightContextActions,
} from "./modes/home/context";
const openSpotlight = spotlightActions.open;
+1 -1
View File
@@ -4,7 +4,7 @@ import type { SearchGroup } from "./group";
export type SearchMode = {
modeKey: keyof TranslationObject["search"]["mode"];
character: string | undefined;
character: string;
} & (
| {
groups: SearchGroup[];
@@ -1,34 +0,0 @@
import { Group, Text } from "@mantine/core";
import { createGroup } from "../../lib/group";
import type { ContextSpecificItem } from "../home/context";
import { useSpotlightContextActions } from "../home/context";
export const contextSpecificActionsSearchGroups = createGroup<ContextSpecificItem>({
title: (t) => t("search.mode.command.group.localCommand.title"),
keyPath: "id",
Component(option) {
const icon =
typeof option.icon !== "string" ? (
<option.icon size={24} />
) : (
<img width={24} height={24} src={option.icon} alt={option.name} />
);
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
{icon}
<Text>{option.name}</Text>
</Group>
);
},
useInteraction(option) {
return option.interaction();
},
filter(query, option) {
return option.name.toLowerCase().includes(query.toLowerCase());
},
useOptions() {
return useSpotlightContextActions();
},
});
@@ -1,166 +0,0 @@
import { Group, Text, useMantineColorScheme } from "@mantine/core";
import type { TablerIcon } from "@tabler/icons-react";
import {
IconBox,
IconCategoryPlus,
IconFileImport,
IconLanguage,
IconMailForward,
IconMoon,
IconPlug,
IconSun,
IconUserPlus,
IconUsersGroup,
} from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal, AddGroupModal, ImportBoardModal, InviteCreateModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
import { interaction } from "../../lib/interaction";
import { languageChildrenOptions } from "./children/language";
import { newIntegrationChildrenOptions } from "./children/new-integration";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type Command<TSearchInteraction extends SearchInteraction = SearchInteraction> = {
commandKey: string;
icon: TablerIcon;
name: string;
useInteraction: (
_c: Command<TSearchInteraction>,
query: string,
) => inferSearchInteractionDefinition<TSearchInteraction>;
};
export const globalCommandGroup = createGroup<Command>({
keyPath: "commandKey",
title: "Global commands",
useInteraction: (option, query) => option.useInteraction(option, query),
Component: ({ icon: Icon, name }) => (
<Group px="md" py="sm">
<Icon stroke={1.5} />
<Text>{name}</Text>
</Group>
),
filter(query, option) {
return option.name.toLowerCase().includes(query.toLowerCase());
},
useOptions() {
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
const { colorScheme } = useMantineColorScheme();
const { data: session } = useSession();
const commands: (Command & { hidden?: boolean })[] = [
{
commandKey: "colorScheme",
icon: colorScheme === "dark" ? IconSun : IconMoon,
name: tOption(`colorScheme.${colorScheme === "dark" ? "light" : "dark"}`),
useInteraction: () => {
const { toggleColorScheme } = useMantineColorScheme();
return {
type: "javaScript",
onSelect: toggleColorScheme,
};
},
},
{
commandKey: "language",
icon: IconLanguage,
name: tOption("language.label"),
useInteraction: interaction.children(languageChildrenOptions),
},
{
commandKey: "newBoard",
icon: IconCategoryPlus,
name: tOption("newBoard.label"),
useInteraction() {
const { openModal } = useModalAction(AddBoardModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
hidden: !session?.user.permissions.includes("board-create"),
},
{
commandKey: "importBoard",
icon: IconFileImport,
name: tOption("importBoard.label"),
useInteraction() {
const { openModal } = useModalAction(ImportBoardModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
hidden: !session?.user.permissions.includes("board-create"),
},
{
commandKey: "newApp",
icon: IconBox,
name: tOption("newApp.label"),
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
hidden: !session?.user.permissions.includes("app-create"),
},
{
commandKey: "newIntegration",
icon: IconPlug,
name: tOption("newIntegration.label"),
useInteraction: interaction.children(newIntegrationChildrenOptions),
hidden: !session?.user.permissions.includes("integration-create"),
},
{
commandKey: "newUser",
icon: IconUserPlus,
name: tOption("newUser.label"),
useInteraction: interaction.link(() => ({ href: "/manage/users/new" })),
hidden: !session?.user.permissions.includes("admin"),
},
{
commandKey: "newInvite",
icon: IconMailForward,
name: tOption("newInvite.label"),
useInteraction() {
const { openModal } = useModalAction(InviteCreateModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
hidden: !session?.user.permissions.includes("admin"),
},
{
commandKey: "newGroup",
icon: IconUsersGroup,
name: tOption("newGroup.label"),
useInteraction() {
const { openModal } = useModalAction(AddGroupModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
hidden: !session?.user.permissions.includes("admin"),
},
];
return commands.filter((command) => !command.hidden);
},
});
+167 -3
View File
@@ -1,9 +1,173 @@
import { Group, Text, useMantineColorScheme } from "@mantine/core";
import {
IconBox,
IconCategoryPlus,
IconFileImport,
IconLanguage,
IconMailForward,
IconMoon,
IconPlug,
IconSun,
IconUserPlus,
IconUsersGroup,
} from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal, AddGroupModal, ImportBoardModal, InviteCreateModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
import { interaction } from "../../lib/interaction";
import type { SearchMode } from "../../lib/mode";
import { contextSpecificActionsSearchGroups } from "./context-specific-group";
import { globalCommandGroup } from "./global-group";
import { languageChildrenOptions } from "./children/language";
import { newIntegrationChildrenOptions } from "./children/new-integration";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type Command<TSearchInteraction extends SearchInteraction = SearchInteraction> = {
commandKey: string;
icon: TablerIcon;
name: string;
useInteraction: (
_c: Command<TSearchInteraction>,
query: string,
) => inferSearchInteractionDefinition<TSearchInteraction>;
};
export const commandMode = {
modeKey: "command",
character: ">",
groups: [contextSpecificActionsSearchGroups, globalCommandGroup],
groups: [
createGroup<Command>({
keyPath: "commandKey",
title: "Global commands",
useInteraction: (option, query) => option.useInteraction(option, query),
Component: ({ icon: Icon, name }) => (
<Group px="md" py="sm">
<Icon stroke={1.5} />
<Text>{name}</Text>
</Group>
),
filter(query, option) {
return option.name.toLowerCase().includes(query.toLowerCase());
},
useOptions() {
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
const { colorScheme } = useMantineColorScheme();
const { data: session } = useSession();
const commands: (Command & { hidden?: boolean })[] = [
{
commandKey: "colorScheme",
icon: colorScheme === "dark" ? IconSun : IconMoon,
name: tOption(`colorScheme.${colorScheme === "dark" ? "light" : "dark"}`),
useInteraction: () => {
const { toggleColorScheme } = useMantineColorScheme();
return {
type: "javaScript",
onSelect: toggleColorScheme,
};
},
},
{
commandKey: "language",
icon: IconLanguage,
name: tOption("language.label"),
useInteraction: interaction.children(languageChildrenOptions),
},
{
commandKey: "newBoard",
icon: IconCategoryPlus,
name: tOption("newBoard.label"),
useInteraction() {
const { openModal } = useModalAction(AddBoardModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
hidden: !session?.user.permissions.includes("board-create"),
},
{
commandKey: "importBoard",
icon: IconFileImport,
name: tOption("importBoard.label"),
useInteraction() {
const { openModal } = useModalAction(ImportBoardModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
hidden: !session?.user.permissions.includes("board-create"),
},
{
commandKey: "newApp",
icon: IconBox,
name: tOption("newApp.label"),
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
hidden: !session?.user.permissions.includes("app-create"),
},
{
commandKey: "newIntegration",
icon: IconPlug,
name: tOption("newIntegration.label"),
useInteraction: interaction.children(newIntegrationChildrenOptions),
hidden: !session?.user.permissions.includes("integration-create"),
},
{
commandKey: "newUser",
icon: IconUserPlus,
name: tOption("newUser.label"),
useInteraction: interaction.link(() => ({ href: "/manage/users/new" })),
hidden: !session?.user.permissions.includes("admin"),
},
{
commandKey: "newInvite",
icon: IconMailForward,
name: tOption("newInvite.label"),
useInteraction() {
const { openModal } = useModalAction(InviteCreateModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
hidden: !session?.user.permissions.includes("admin"),
},
{
commandKey: "newGroup",
icon: IconUsersGroup,
name: tOption("newGroup.label"),
useInteraction() {
const { openModal } = useModalAction(AddGroupModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
hidden: !session?.user.permissions.includes("admin"),
},
];
return commands.filter((command) => !command.hidden);
},
}),
],
} satisfies SearchMode;
@@ -1,34 +0,0 @@
import { Group, Text } from "@mantine/core";
import { createGroup } from "../../lib/group";
import type { ContextSpecificItem } from "./context";
import { useSpotlightContextResults } from "./context";
export const contextSpecificSearchGroups = createGroup<ContextSpecificItem>({
title: (t) => t("search.mode.home.group.local.title"),
keyPath: "id",
Component(option) {
const icon =
typeof option.icon !== "string" ? (
<option.icon size={24} />
) : (
<img width={24} height={24} src={option.icon} alt={option.name} />
);
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
{icon}
<Text>{option.name}</Text>
</Group>
);
},
useInteraction(option) {
return option.interaction();
},
filter(query, option) {
return option.name.toLowerCase().includes(query.toLowerCase());
},
useOptions() {
return useSpotlightContextResults();
},
});
@@ -1,122 +0,0 @@
import type { DependencyList, PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { TablerIcon } from "@homarr/ui";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type ContextSpecificItem = {
id: string;
name: string;
icon: TablerIcon | string;
interaction: () => inferSearchInteractionDefinition<SearchInteraction>;
disabled?: boolean;
};
interface SpotlightContextProps {
items: ContextSpecificItem[];
registerItems: (key: string, results: ContextSpecificItem[]) => void;
unregisterItems: (key: string) => void;
}
const createSpotlightContext = (displayName: string) => {
const SpotlightContext = createContext<SpotlightContextProps | null>(null);
SpotlightContext.displayName = displayName;
const Provider = ({ children }: PropsWithChildren) => {
const [itemsMap, setItemsMap] = useState<Map<string, { items: ContextSpecificItem[]; count: number }>>(new Map());
const registerItems = useCallback((key: string, newItems: ContextSpecificItem[]) => {
setItemsMap((prevItems) => {
const newItemsMap = new Map(prevItems);
newItemsMap.set(key, { items: newItems, count: (newItemsMap.get(key)?.count ?? 0) + 1 });
return newItemsMap;
});
}, []);
const unregisterItems = useCallback((key: string) => {
setItemsMap((prevItems) => {
const registrationCount = prevItems.get(key)?.count ?? 0;
if (registrationCount <= 1) {
const newItemsMap = new Map(prevItems);
newItemsMap.delete(key);
return newItemsMap;
}
const newItemsMap = new Map(prevItems);
newItemsMap.set(key, { items: newItemsMap.get(key)?.items ?? [], count: registrationCount - 1 });
return prevItems;
});
}, []);
const items = useMemo(() => Array.from(itemsMap.values()).flatMap(({ items }) => items), [itemsMap]);
return (
<SpotlightContext.Provider value={{ items, registerItems, unregisterItems }}>
{children}
</SpotlightContext.Provider>
);
};
const useSpotlightContextItems = () => {
const context = useContext(SpotlightContext);
if (!context) {
throw new Error(`useSpotlightContextItems must be used within SpotlightContext[displayName=${displayName}]`);
}
return context.items;
};
const useRegisterSpotlightContextItems = (
key: string,
items: ContextSpecificItem[],
dependencyArray: DependencyList,
) => {
const context = useContext(SpotlightContext);
if (!context) {
throw new Error(
`useRegisterSpotlightContextItems must be used within SpotlightContext[displayName=${displayName}]`,
);
}
useEffect(() => {
context.registerItems(
key,
items.filter((item) => !item.disabled),
);
return () => {
context.unregisterItems(key);
};
// We ignore the results
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...dependencyArray, key]);
};
return [SpotlightContext, Provider, useSpotlightContextItems, useRegisterSpotlightContextItems] as const;
};
const [_ResultContext, ResultProvider, useSpotlightContextResults, useRegisterSpotlightContextResults] =
createSpotlightContext("SpotlightContextSpecificResults");
const [_ActionContext, ActionProvider, useSpotlightContextActions, useRegisterSpotlightContextActions] =
createSpotlightContext("SpotlightContextSpecificActions");
export {
useRegisterSpotlightContextActions,
useRegisterSpotlightContextResults,
useSpotlightContextActions,
useSpotlightContextResults,
};
export const SpotlightProvider = ({ children }: PropsWithChildren) => {
return (
<ResultProvider>
<ActionProvider>{children}</ActionProvider>
</ResultProvider>
);
};
@@ -1,8 +0,0 @@
import type { SearchMode } from "../../lib/mode";
import { contextSpecificSearchGroups } from "./context-specific-group";
export const homeMode = {
character: undefined,
modeKey: "home",
groups: [contextSpecificSearchGroups],
} satisfies SearchMode;
+2 -3
View File
@@ -11,11 +11,10 @@ import type { SearchMode } from "../lib/mode";
import { appIntegrationBoardMode } from "./app-integration-board";
import { commandMode } from "./command";
import { externalMode } from "./external";
import { homeMode } from "./home";
import { pageMode } from "./page";
import { userGroupMode } from "./user-group";
const searchModesForHelp = [userGroupMode, appIntegrationBoardMode, externalMode, commandMode, pageMode] as const;
const searchModesWithoutHelp = [userGroupMode, appIntegrationBoardMode, externalMode, commandMode, pageMode] as const;
const helpMode = {
modeKey: "help",
@@ -83,4 +82,4 @@ const helpMode = {
},
} satisfies SearchMode;
export const searchModes = [...searchModesForHelp, helpMode, homeMode] as const;
export const searchModes = [...searchModesWithoutHelp, helpMode] as const;
+1 -1
View File
@@ -33,7 +33,7 @@
"deepmerge": "4.3.1",
"mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.20",
"next-intl": "3.26.0",
"next-intl": "3.25.3",
"react": "^18.3.1"
},
"devDependencies": {
+3 -17
View File
@@ -733,8 +733,7 @@
"logout": "Logout",
"login": "Login",
"homeBoard": "Your home board",
"loggedOut": "Logged out",
"updateAvailable": "{countUpdates} updates available: {tag}"
"loggedOut": "Logged out"
}
},
"dangerZone": "Danger zone",
@@ -1160,9 +1159,6 @@
"automationId": {
"label": "Automation ID"
}
},
"spotlightAction": {
"run": "Run {name}"
}
},
"calendar": {
@@ -2155,8 +2151,8 @@
"sessionCleanup": {
"label": "Session Cleanup"
},
"updateChecker": {
"label": "Update checker"
"notifications-fetcher": {
"label": "Notifications fetcher"
}
}
},
@@ -2457,9 +2453,6 @@
"command": {
"help": "Activate command mode",
"group": {
"localCommand": {
"title": "Local commands"
},
"globalCommand": {
"title": "Global commands",
"option": {
@@ -2569,13 +2562,6 @@
}
}
},
"home": {
"group": {
"local": {
"title": "Local results"
}
}
},
"page": {
"help": "Search for pages",
"group": {
+2 -2
View File
@@ -24,7 +24,7 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/core": "^6.3.0",
"@dnd-kit/sortable": "^10.0.0",
"@extractus/feed-extractor": "^7.1.3",
"@homarr/api": "workspace:^0.1.0",
@@ -64,7 +64,7 @@
"mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.20",
"react": "^18.3.1",
"video.js": "^8.21.0"
"video.js": "^8.20.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
-19
View File
@@ -8,7 +8,6 @@ import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
import { parseAppHrefWithVariablesClient } from "@homarr/common/client";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
@@ -29,24 +28,6 @@ export default function AppWidget({ options, isEditMode }: WidgetComponentProps<
retry: false,
},
);
useRegisterSpotlightContextResults(
`app-${app.id}`,
[
{
id: app.id,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link",
href: parseAppHrefWithVariablesClient(app.href ?? ""),
newTab: options.openInNewTab,
};
},
},
],
[app, options.openInNewTab],
);
return (
<AppLink
+1 -20
View File
@@ -4,36 +4,17 @@ import { Anchor, Box, Card, Divider, Flex, Group, Stack, Text, Title, UnstyledBu
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { parseAppHrefWithVariablesClient } from "@homarr/common/client";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import type { WidgetComponentProps } from "../definition";
import classes from "./bookmark.module.css";
export default function BookmarksWidget({ options, width, height, itemId }: WidgetComponentProps<"bookmarks">) {
export default function BookmarksWidget({ options, width, height }: WidgetComponentProps<"bookmarks">) {
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.map((app) => ({
id: app.id,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link",
href: parseAppHrefWithVariablesClient(app.href ?? ""),
newTab: false,
};
},
})),
[data],
);
return (
<Stack h="100%" gap="sm" p="sm">
<Title order={4} px="0.25rem">
@@ -169,15 +169,15 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
</List.Item>
<List m="0.5cqmin" withPadding center spacing="0.5cqmin" icon={<IconCpu size="1cqmin" />}>
<List.Item className="health-monitoring-information-load-average-1min">
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}%
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}
</List.Item>
<List.Item className="health-monitoring-information-load-average-5min">
{t("widget.healthMonitoring.popover.minutes", { count: 5 })}{" "}
{healthInfo.loadAverage["5min"]}%
{healthInfo.loadAverage["5min"]}
</List.Item>
<List.Item className="health-monitoring-information-load-average-15min">
{t("widget.healthMonitoring.popover.minutes", { count: 15 })}{" "}
{healthInfo.loadAverage["15min"]}%
{healthInfo.loadAverage["15min"]}
</List.Item>
</List>
</List>
@@ -363,7 +363,7 @@ const CpuTempRing = ({ fahrenheit, cpuTemp }: { fahrenheit: boolean; cpuTemp: nu
label={
<Center style={{ flexDirection: "column" }}>
<Text className="health-monitoring-cpu-temp-value" size="3cqmin">
{fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp.toFixed(1)}°C`}
{fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp}°C`}
</Text>
<IconCpu className="health-monitoring-cpu-temp-icon" size="7cqmin" />
</Center>
@@ -1,7 +1,5 @@
import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -21,7 +19,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
defaultValue: true,
}),
})),
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"),
supportedIntegrations: ["openmediavault"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
@@ -2,10 +2,8 @@
import { useCallback } from "react";
import { Center, Stack, Text, UnstyledButton } from "@mantine/core";
import { IconBinaryTree } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationSelectedError } from "../../errors";
@@ -62,27 +60,6 @@ const InnerComponent = ({ options, integrationId, isEditMode }: InnerComponentPr
});
}, [integrationId, isEditMode, mutate, options.clickable, options.entityId]);
useRegisterSpotlightContextActions(
`smartHome-entityState-${options.entityId}`,
[
{
id: options.entityId,
name: options.displayName,
icon: IconBinaryTree,
interaction() {
return {
type: "javaScript",
onSelect() {
handleClick();
},
};
},
disabled: !options.clickable,
},
],
[handleClick, options.clickable, options.displayName, options.entityId],
);
return (
<UnstyledButton
onClick={handleClick}
@@ -6,8 +6,6 @@ import { useDisclosure, useTimeout } from "@mantine/hooks";
import { IconAutomation, IconCheck } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
@@ -36,29 +34,6 @@ export default function SmartHomeTriggerAutomationWidget({
integrationId: integrationIds[0] ?? "",
});
}, [integrationIds, isEditMode, mutateAsync, options.automationId]);
const t = useI18n();
useRegisterSpotlightContextActions(
`smartHome-automation-${options.automationId}`,
[
{
id: options.automationId,
name: t("widget.smartHome-executeAutomation.spotlightAction.run", { name: options.displayName }),
icon: IconAutomation,
interaction() {
return {
type: "javaScript",
// eslint-disable-next-line no-restricted-syntax
async onSelect() {
await handleClick();
},
};
},
},
],
[handleClick, options.automationId, options.displayName],
);
return (
<UnstyledButton onClick={handleClick} style={{ cursor: !isEditMode ? "pointer" : "initial" }} w="100%" h="100%">
{isShowSuccess && (
+142 -444
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -23,7 +23,7 @@
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-hooks": "^5.0.0",
"typescript-eslint": "^8.17.0"
},
"devDependencies": {