Merge origin/release/v2 into feat/fix-v2-stack-review-findings

This commit is contained in:
ajnart committed 2026-08-21 19:08:02 +02:00
commit e1775e524b
583 files changed
+16433 -9067

No files matched your search

+2 -4
View File
@@ -17,8 +17,6 @@ import {
users,
} from "@homarr/db/schema";
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
import type { TranslationObject } from "@homarr/translation";
import { createTRPCRouter, publicProcedure } from "../trpc";
const getFullAccessIntegrationIdsAsync = async (db: Database, userId: string) => {
@@ -49,8 +47,8 @@ const getFullAccessIntegrationIdsAsync = async (db: Database, userId: string) =>
};
interface HomeStatistic {
titleKey: keyof TranslationObject["management"]["page"]["home"]["statistic"];
subtitleKey: keyof TranslationObject["management"]["page"]["home"]["statisticLabel"];
titleKey: "app" | "board" | "group" | "integration" | "invite" | "media" | "searchEngine" | "user";
subtitleKey: "authentication" | "authorization" | "boards" | "resources";
count: number;
path: string;
}
@@ -1,6 +1,7 @@
import type { V1NodeList, VersionInfo } from "@kubernetes/client-node";
import { TRPCError } from "@trpc/server";
import { invariantTechnicalLabels } from "@homarr/definitions";
import type { ClusterResourceCount, KubernetesCluster } from "@homarr/definitions";
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
@@ -81,7 +82,7 @@ export const clusterRouter = createTRPCRouter({
metricsAvailable: nodeMetricsClient !== null,
capacity: [
{
type: "CPU",
type: invariantTechnicalLabels.cpu,
resourcesStats: [
{
percentageValue: reservedCPUPercentage,
+357
View File
@@ -0,0 +1,357 @@
import { randomBytes } from "node:crypto";
import { constants } from "node:fs";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { command, number, positional, string } from "@drizzle-team/brocli";
const execFileAsync = promisify(execFile);
const environmentSeparator = "\u0000";
const validEnvironmentName = /^[A-Za-z_][A-Za-z0-9_]*$/;
const validHarnessName = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
const demoEnvironment: ReadonlyArray<[string, string]> = [
["DEMO_MODE", "true"],
["DEMO_READ_ONLY", "false"],
["UNSAFE_ENABLE_MOCK_INTEGRATION", "true"],
];
type HarnessRuntimeOptions = {
name: string;
env?: string;
port?: number;
};
const runtimeOptions = () => ({
name: positional("name").required().desc("Name used for the image, container, and data volume"),
env: string("env").alias("e").desc("Environment override KEY=VALUE; repeat the flag for more values"),
port: number("port").int().min(0).max(65535).desc("Host port, or 0 for an automatically assigned port"),
});
function validateHarnessName(name: string): string {
if (!validHarnessName.test(name)) {
throw new Error("Harness name must be 1-63 characters and contain only letters, numbers, '.', '_' or '-'");
}
return name;
}
function imageName(name: string): string {
return `homarr:${validateHarnessName(name)}`;
}
function containerName(name: string): string {
return `homarr-harness-${validateHarnessName(name)}`;
}
function volumeName(name: string): string {
return `homarr-harness-${validateHarnessName(name)}-data`;
}
function hasErrorCode(error: unknown, code: string): boolean {
return error instanceof Error && "code" in error && error.code === code;
}
function commandError(commandName: string, error: unknown): Error {
if (hasErrorCode(error, "ENOENT")) {
return new Error(`${commandName} is required but was not found on PATH`);
}
if (error instanceof Error) {
return new Error(`${commandName} failed: ${error.message}`);
}
return new Error(`${commandName} failed`);
}
async function execCommand(commandName: string, args: string[], cwd?: string): Promise<string> {
try {
const result = await execFileAsync(commandName, args, {
cwd,
maxBuffer: 10 * 1024 * 1024,
});
return result.stdout.trim();
} catch (error) {
throw commandError(commandName, error);
}
}
async function runCommand(commandName: string, args: string[], cwd?: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(commandName, args, {
cwd,
stdio: "inherit",
});
child.once("error", (error) => reject(commandError(commandName, error)));
child.once("close", (code, signal) => {
if (code === 0) {
resolve();
return;
}
const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
reject(new Error(`${commandName} failed with ${reason}`));
});
});
}
async function repositoryRoot(): Promise<string> {
const root = await execCommand("git", ["rev-parse", "--show-toplevel"], process.cwd());
try {
await access(join(root, "Dockerfile"), constants.R_OK);
} catch {
throw new Error("Run the harness command from a Homarr checkout containing a Dockerfile");
}
return root;
}
function parseEnvironmentOverrides(raw?: string): Map<string, string> {
const values = raw?.split(environmentSeparator) ?? [];
const environment = new Map(demoEnvironment);
for (const value of values) {
const separator = value.indexOf("=");
if (separator <= 0) {
throw new Error(`Invalid environment override ${JSON.stringify(value)}; use KEY=VALUE`);
}
const name = value.slice(0, separator);
if (!validEnvironmentName.test(name)) {
throw new Error(`Invalid environment variable name ${JSON.stringify(name)}`);
}
environment.set(name, value.slice(separator + 1));
}
return environment;
}
function isEncryptionKey(value: string | undefined): value is string {
return value !== undefined && /^[a-f0-9]{64}$/i.test(value);
}
async function readEncryptionKey(path: string): Promise<string | undefined> {
try {
const value = (await readFile(path, "utf8")).trim();
if (isEncryptionKey(value)) {
return value;
}
throw new Error(`Invalid encryption key in ${path}`);
} catch (error) {
if (hasErrorCode(error, "ENOENT")) {
return undefined;
}
throw error;
}
}
async function harnessEncryptionKey(name: string): Promise<string> {
if (isEncryptionKey(process.env.SECRET_ENCRYPTION_KEY)) {
return process.env.SECRET_ENCRYPTION_KEY;
}
const configRoot = process.env.HOMARR_HARNESS_CONFIG_DIR ?? join(homedir(), ".config", "homarr");
const keyPath = join(configRoot, "local-harness", `${validateHarnessName(name)}.key`);
const existingKey = await readEncryptionKey(keyPath);
if (existingKey) {
return existingKey;
}
const newKey = randomBytes(32).toString("hex");
await mkdir(join(configRoot, "local-harness"), { recursive: true, mode: 0o700 });
try {
await writeFile(keyPath, `${newKey}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
return newKey;
} catch (error) {
if (!hasErrorCode(error, "EEXIST")) {
throw error;
}
const racedKey = await readEncryptionKey(keyPath);
if (racedKey) {
return racedKey;
}
throw new Error(`Could not read the harness encryption key at ${keyPath}`, { cause: error });
}
}
async function harnessEnvironment(name: string, raw?: string): Promise<string[]> {
const environment = parseEnvironmentOverrides(raw);
if (!environment.has("SECRET_ENCRYPTION_KEY")) {
environment.set("SECRET_ENCRYPTION_KEY", await harnessEncryptionKey(name));
}
return Array.from(environment, ([key, value]) => `${key}=${value}`);
}
async function buildHarnessImage(name: string): Promise<void> {
const root = await repositoryRoot();
const image = imageName(name);
console.log(`Building ${image} from ${root}`);
await runCommand("docker", ["build", "--tag", image, "."], root);
console.log(`Built ${image}`);
}
async function removeExistingContainer(name: string): Promise<void> {
try {
await execCommand("docker", ["rm", "--force", containerName(name)]);
} catch {
// A missing container is the normal first-run path. Docker reports it as
// an error, so the cleanup is intentionally best-effort.
}
}
async function publishedPort(name: string): Promise<number> {
const output = await execCommand("docker", ["port", containerName(name), "7575/tcp"]);
for (const line of output.split("\n")) {
const match = line.trim().match(/:(\d+)$/);
if (match) {
return Number(match[1]);
}
}
throw new Error(`Container ${containerName(name)} has no published port for 7575/tcp`);
}
async function startHarness(options: HarnessRuntimeOptions): Promise<void> {
const name = validateHarnessName(options.name);
const image = imageName(name);
const container = containerName(name);
const requestedPort = options.port ?? 0;
const environment = await harnessEnvironment(name, options.env);
await removeExistingContainer(name);
console.log(`Starting ${container} from ${image}`);
await runCommand("docker", [
"run",
"--detach",
"--name",
container,
"--publish",
`${requestedPort}:7575`,
"--volume",
`${volumeName(name)}:/appdata`,
...environment.flatMap((value) => ["--env", value]),
image,
]);
const port = await publishedPort(name);
console.log(`Harness ${name} is running`);
console.log(` Container : ${container}`);
console.log(` Image : ${image}`);
console.log(` Port : ${port}`);
console.log(` URL : http://127.0.0.1:${port}`);
}
export const normalizeHarnessEnvironmentArgs = (argv: string[]): string[] => {
const normalized = argv.slice(0, 2);
const args = argv.slice(2);
const environmentValues: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === undefined) {
continue;
}
if (arg === "--env" || arg === "-e") {
const value = args[index + 1];
if (value === undefined) {
normalized.push(arg);
continue;
}
environmentValues.push(value);
index += 1;
continue;
}
if (arg.startsWith("--env=")) {
environmentValues.push(arg.slice("--env=".length));
continue;
}
normalized.push(arg);
}
if (environmentValues.length > 0) {
normalized.push(`--env=${environmentValues.join(environmentSeparator)}`);
}
return normalized;
};
const harnessBuild = command({
name: "build",
desc: "Build the current checkout as a named local Homarr image",
options: {
name: positional("name").required().desc("Local image tag"),
},
handler: async ({ name }) => buildHarnessImage(name),
});
const harnessRun = command({
name: "run",
aliases: ["start", "up"],
desc: "Run a named local image in writable demo mode",
options: runtimeOptions(),
handler: async (options) => startHarness(options),
});
const harnessSetup = command({
name: "setup",
aliases: ["create"],
desc: "Build the current checkout and run its writable demo harness",
options: runtimeOptions(),
handler: async (options) => {
await buildHarnessImage(options.name);
await startHarness(options);
},
});
const harnessPort = command({
name: "port",
desc: "Show the published host port and URL for a harness",
options: {
name: positional("name").required().desc("Harness name"),
},
handler: async ({ name }) => {
const port = await publishedPort(name);
console.log(`Port: ${port}`);
console.log(`URL: http://127.0.0.1:${port}`);
},
});
const harnessStop = command({
name: "stop",
aliases: ["down"],
desc: "Stop a local harness container and keep its data volume",
options: {
name: positional("name").required().desc("Harness name"),
},
handler: async ({ name }) => {
const container = containerName(name);
await execCommand("docker", ["stop", container]);
console.log(`Stopped ${container}`);
},
});
export const harnessRoot = command({
name: "harness",
aliases: ["local-harness"],
desc: "Build and run a local branch in a browser-ready demo harness",
subcommands: [harnessBuild, harnessRun, harnessSetup, harnessPort, harnessStop],
});
+38 -10
View File
@@ -1,14 +1,42 @@
import { run } from "@drizzle-team/brocli";
import type { Command } from "@drizzle-team/brocli";
import { fixUsernames } from "./commands/fix-usernames";
import { integrationsRoot } from "./commands/integrations";
import { recreateAdmin } from "./commands/recreate-admin";
import { resetPassword } from "./commands/reset-password";
import { usersRoot } from "./commands/users";
import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness";
const commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin];
const harnessCommandNames = new Set(["harness", "local-harness"]);
void run(commands, {
name: "homarr-cli",
version: "1.0.0",
});
function isHarnessInvocation(argv: string[]): boolean {
return argv.slice(2).some((argument) => harnessCommandNames.has(argument));
}
async function main(): Promise<void> {
const harnessInvocation = isHarnessInvocation(process.argv);
let commands: Command[];
if (harnessInvocation) {
commands = [harnessRoot];
} else {
const [{ fixUsernames }, { integrationsRoot }, { recreateAdmin }, { resetPassword }, { usersRoot }] =
await Promise.all([
import("./commands/fix-usernames"),
import("./commands/integrations"),
import("./commands/recreate-admin"),
import("./commands/reset-password"),
import("./commands/users"),
]);
commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin, harnessRoot];
}
let argSource = process.argv;
if (harnessInvocation) {
argSource = normalizeHarnessEnvironmentArgs(process.argv);
}
await run(commands, {
argSource,
name: "homarr-cli",
version: "1.0.0",
});
}
void main();
+14
View File
@@ -35,6 +35,7 @@ export const resolveAssistantModelId = (models: AssistantModelOption[], value: s
const providerIconBaseUrl = "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons";
interface AssistantProviderPreset {
label?: string;
baseUrl: string;
modelDiscoveryPath: string | null;
requiresApiKey: boolean;
@@ -46,6 +47,7 @@ interface AssistantProviderPreset {
export const assistantProviderPresets = {
homarr: {
label: "Homarr",
// Browser and server code replace this public fallback with WORKSHOP_API_URL at runtime.
baseUrl: "https://homarr.dev/api/ai/v1",
modelDiscoveryPath: "/models",
@@ -55,6 +57,7 @@ export const assistantProviderPresets = {
iconUrl: "https://homarr.dev/img/logo.png",
},
openrouter: {
label: "OpenRouter",
baseUrl: "https://openrouter.ai/api/v1",
modelDiscoveryPath: "/models",
requiresApiKey: true,
@@ -63,6 +66,7 @@ export const assistantProviderPresets = {
iconUrl: `${providerIconBaseUrl}/openrouter-color.svg`,
},
openai: {
label: "OpenAI",
baseUrl: "https://api.openai.com/v1",
modelDiscoveryPath: "/models",
requiresApiKey: true,
@@ -71,6 +75,7 @@ export const assistantProviderPresets = {
iconUrl: `${providerIconBaseUrl}/openai.svg`,
},
anthropic: {
label: "Anthropic Claude",
baseUrl: "https://api.anthropic.com/v1",
modelDiscoveryPath: "/models",
requiresApiKey: true,
@@ -79,6 +84,7 @@ export const assistantProviderPresets = {
iconUrl: `${providerIconBaseUrl}/anthropic.svg`,
},
"google-gemini": {
label: "Google Gemini",
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
modelDiscoveryPath: "/models",
requiresApiKey: true,
@@ -87,6 +93,7 @@ export const assistantProviderPresets = {
iconUrl: `${providerIconBaseUrl}/gemini-color.svg`,
},
xai: {
label: "xAI",
baseUrl: "https://api.x.ai/v1",
modelDiscoveryPath: "/models",
requiresApiKey: true,
@@ -95,6 +102,7 @@ export const assistantProviderPresets = {
iconUrl: `${providerIconBaseUrl}/xai.svg`,
},
groq: {
label: "Groq",
baseUrl: "https://api.groq.com/openai/v1",
modelDiscoveryPath: "/models",
requiresApiKey: true,
@@ -104,6 +112,7 @@ export const assistantProviderPresets = {
darkIconUrl: "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@latest/dark/groq.png",
},
mistral: {
label: "Mistral AI",
baseUrl: "https://api.mistral.ai/v1",
modelDiscoveryPath: "/models",
requiresApiKey: true,
@@ -112,6 +121,7 @@ export const assistantProviderPresets = {
iconUrl: `${providerIconBaseUrl}/mistral-color.svg`,
},
deepseek: {
label: "DeepSeek",
baseUrl: "https://api.deepseek.com",
modelDiscoveryPath: "/models",
requiresApiKey: true,
@@ -120,6 +130,7 @@ export const assistantProviderPresets = {
iconUrl: `${providerIconBaseUrl}/deepseek-color.svg`,
},
together: {
label: "Together AI",
baseUrl: "https://api.together.xyz/v1",
modelDiscoveryPath: "/models",
requiresApiKey: true,
@@ -128,6 +139,7 @@ export const assistantProviderPresets = {
iconUrl: `${providerIconBaseUrl}/together-color.svg`,
},
ollama: {
label: "Ollama",
baseUrl: "http://localhost:11434/v1",
modelDiscoveryPath: "/models",
requiresApiKey: false,
@@ -136,6 +148,7 @@ export const assistantProviderPresets = {
iconUrl: `${providerIconBaseUrl}/ollama.svg`,
},
"lm-studio": {
label: "LM Studio",
baseUrl: "http://localhost:1234/v1",
modelDiscoveryPath: "/models",
requiresApiKey: false,
@@ -144,6 +157,7 @@ export const assistantProviderPresets = {
iconUrl: `${providerIconBaseUrl}/lmstudio.svg`,
},
custom: {
label: undefined,
baseUrl: "",
modelDiscoveryPath: "/models",
requiresApiKey: false,
@@ -336,7 +336,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/whats-up-docker"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
| "/docs/widgets/today"
| "/docs/widgets/air-quality"
| "/docs/widgets/countdown"
| "/docs/widgets/timer"
@@ -3,7 +3,6 @@ import type { WidgetKind } from "../widget";
export const widgetDocSlugs: Record<WidgetKind, string | null> = {
clock: "clock",
weather: "weather",
today: "today",
airQuality: "air-quality",
countdown: "countdown",
timer: "timer",
+2
View File
@@ -2,6 +2,7 @@ export * from "./board";
export * from "./integration";
export * from "./section";
export * from "./widget";
export * from "./widget-name";
export * from "./permissions";
export * from "./docker";
export * from "./kubernetes";
@@ -20,3 +21,4 @@ export * from "./docker-service-discovery";
export * from "./integration-url-template";
export * from "./release-provider";
export * from "./assistant";
export * from "./invariant-technical-labels";
@@ -0,0 +1,14 @@
export const invariantTechnicalLabels = {
cpu: "CPU",
docker: "Docker",
gpu: "GPU",
ram: "RAM",
os: "OS",
id: "ID",
json: "JSON",
jsonSchema: "JSON Schema",
jsx: "JSX",
url: "URL",
} as const;
export type InvariantTechnicalLabel = (typeof invariantTechnicalLabels)[keyof typeof invariantTechnicalLabels];
+3 -2
View File
@@ -1,7 +1,9 @@
import { invariantTechnicalLabels } from "./invariant-technical-labels";
export const kubernetesNodeStates = ["Ready", "NotReady"] as const;
export const kubernetesNamespaceStates = ["Active", "Terminating"] as const;
export const kubernetesResourceTypes = ["Reserved", "Used"] as const;
export const kubernetesCapacityTypes = ["Pods", "CPU", "Memory"] as const;
export const kubernetesCapacityTypes = ["Pods", invariantTechnicalLabels.cpu, "Memory"] as const;
export const kubernetesLabelResourceTypes = [
"configmaps",
"pods",
@@ -12,7 +14,6 @@ export const kubernetesLabelResourceTypes = [
"services",
"volumes",
] as const;
export type KubernetesNodeState = (typeof kubernetesNodeStates)[number];
export type KubernetesNamespaceState = (typeof kubernetesNamespaceStates)[number];
export type KubernetesResourceType = (typeof kubernetesResourceTypes)[number];
+24
View File
@@ -0,0 +1,24 @@
import type { IntegrationKind } from "./integration";
import { getIntegrationName } from "./integration";
import type { WidgetKind } from "./widget";
const widgetIntegrationKinds: Partial<Record<WidgetKind, IntegrationKind>> = {
coolify: "coolify",
tracearr: "tracearr",
paperlessNgx: "paperlessNgx",
patchmon: "patchmon",
bazarr: "bazarr",
uptimeKuma: "uptimeKuma",
traefik: "traefik",
};
export const getWidgetIntegrationName = (kind: WidgetKind): string | undefined => {
const integrationKind = widgetIntegrationKinds[kind];
if (!integrationKind) return undefined;
return getIntegrationName(integrationKind);
};
type WidgetNameTranslator = (key: never) => string;
export const getWidgetName = (kind: WidgetKind, translate: WidgetNameTranslator): string =>
getWidgetIntegrationName(kind) ?? translate(`widget.${kind}.name` as never);
-2
View File
@@ -1,7 +1,6 @@
export const widgetKinds = [
"clock",
"weather",
"today",
"airQuality",
"countdown",
"timer",
@@ -63,7 +62,6 @@ export const widgetKinds = [
export type WidgetKind = (typeof widgetKinds)[number];
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
today: { width: 2, height: 1 },
airQuality: { width: 2, height: 1 },
countdown: { width: 2, height: 1 },
timer: { width: 2, height: 1 },
+1
View File
@@ -24,6 +24,7 @@
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
@@ -43,7 +43,7 @@ vi.mock("@homarr/auth/client", () => ({
}));
vi.mock("@homarr/translation", () => ({ supportedLanguages: [] }));
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
useI18n: () => (key: string, params?: Record<string, string>) =>
params ? `${key}:${Object.values(params).join(",")}` : key,
}));
vi.mock("../upload-media/upload-media", () => ({
@@ -35,7 +35,7 @@ import {
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { supportedLanguages } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { UploadMedia } from "../upload-media/upload-media";
import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
@@ -72,7 +72,7 @@ export const IconPicker = ({
const [keyboardIndex, setKeyboardIndex] = useState(-1);
const [previewState, setPreviewState] = useState<PreviewState>("idle");
const { data: session } = useSession();
const tCommon = useScopedI18n("common");
const tCommon = useI18n("common");
const [debouncedDraft] = useDebouncedValue(draft, 250);
const directUrl = isDirectImageUrl(draft) ? draft.trim() : null;
@@ -7,7 +7,7 @@ import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app";
import { AppForm } from "./_form";
@@ -19,14 +19,14 @@ export const AppNewForm = ({
showCreateAnother: boolean;
showBackToOverview: boolean;
}) => {
const tScoped = useScopedI18n("app.page.create.notification");
const t = useI18n();
const tScoped = useI18n("app.page.create.notification");
const tCommon = useI18n("common");
const router = useRouter();
const { mutate, isPending } = clientApi.app.create.useMutation({
onError: () => {
showErrorNotification({
title: tScoped("error.title"),
title: tCommon("notification.create.error"),
message: tScoped("error.message"),
});
},
@@ -37,7 +37,7 @@ export const AppNewForm = ({
mutate(values, {
onSuccess() {
showSuccessNotification({
title: tScoped("success.title"),
title: tCommon("notification.create.success"),
message: tScoped("success.message"),
});
afterSuccess?.();
@@ -51,14 +51,14 @@ export const AppNewForm = ({
},
});
},
[mutate, router, tScoped],
[mutate, router, tCommon, tScoped],
);
return (
<AppForm
buttonLabels={{
submit: t("common.action.create"),
submitAndCreateAnother: showCreateAnother ? t("common.action.createAnother") : undefined,
submit: tCommon("action.create"),
submitAndCreateAnother: showCreateAnother ? tCommon("action.createAnother") : undefined,
}}
showBackToOverview={showBackToOverview}
handleSubmit={handleSubmit}
@@ -7,6 +7,7 @@ import { useDisclosure } from "@mantine/hooks";
import type { z } from "zod/v4";
import { useZodForm } from "@homarr/form";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { appManageSchema } from "@homarr/validation/app";
@@ -50,7 +51,8 @@ export const AppForm = ({
hideButtons,
formRef,
}: AppFormProps) => {
const t = useI18n();
const tApp = useI18n("app");
const tCommon = useI18n("common");
const form = useZodForm(appManageSchema, {
initialValues: toFormValues(initialValues),
@@ -127,25 +129,25 @@ export const AppForm = ({
const formFields = (
<Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<TextInput {...form.getInputProps("name")} withAsterisk label={tCommon("field.name")} />
<IconPicker
{...form.getInputProps("iconUrl")}
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/>
<Textarea
{...form.getInputProps("description")}
label={t("app.field.description.label")}
label={tApp("field.description.label")}
autosize
minRows={2}
resize="vertical"
/>
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
<TextInput {...form.getInputProps("href")} label={invariantTechnicalLabels.url} />
<Checkbox
checked={opened}
onChange={handleClickDifferentUrlPing}
label={t("app.field.useDifferentUrlForPing.checkbox.label")}
description={t("app.field.useDifferentUrlForPing.checkbox.description")}
label={tApp("field.useDifferentUrlForPing.checkbox.label")}
description={tApp("field.useDifferentUrlForPing.checkbox.description")}
mt="md"
/>
@@ -157,7 +159,7 @@ export const AppForm = ({
<Group justify="end">
{showBackToOverview && (
<Button variant="default" component={Link} href="/manage/apps">
{t("common.action.backToOverview")}
{tCommon("action.backToOverview")}
</Button>
)}
{buttonLabels.submitAndCreateAnother && (
@@ -3,7 +3,7 @@
import { PasswordInput, Stack } from "@mantine/core";
import type { PasswordInputProps } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
interface UserCreatePasswordFieldsProps {
@@ -17,7 +17,7 @@ export const UserCreatePasswordFields = ({
confirmPasswordInputProps,
variant,
}: UserCreatePasswordFieldsProps) => {
const t = useScopedI18n("user.field");
const t = useI18n("user.field");
return (
<Stack gap="md">
@@ -1,12 +1,25 @@
import { useMemo, useState } from "react";
import { Button, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { IconCheck, IconPlus, IconApps } from "@tabler/icons-react";
import {
Badge,
Button,
Center,
Group,
Image,
Input,
Paper,
ScrollArea,
SimpleGrid,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { IconBulb, IconPlus, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import { FloatingTip, selectGridCols, SelectableCard } from "@homarr/ui";
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
@@ -36,7 +49,15 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
const selectedApps = useMemo(() => apps.filter((app) => selectedAppIds.has(app.id)), [apps, selectedAppIds]);
const handleSelect = (app: SelectableApp) => {
const handleSelect = (app: SelectableApp, event?: React.MouseEvent) => {
const isModifierPressed = multiSelect && Boolean(event?.shiftKey || event?.ctrlKey || event?.metaKey);
if (innerProps.onSelect && !isModifierPressed && selectedAppIds.size === 0) {
innerProps.onSelect(app);
actions.closeModal();
return;
}
if (multiSelect) {
setSelectedAppIds((current) => {
const next = new Set(current);
@@ -44,22 +65,6 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
else next.add(app.id);
return next;
});
return;
}
innerProps.onSelect?.(app);
actions.closeModal();
};
const handleSubmit = () => {
if (multiSelect) {
innerProps.onSelectMany?.(selectedApps);
actions.closeModal();
return;
}
const [first] = filteredApps;
if (first) {
innerProps.onSelect?.(first);
actions.closeModal();
}
};
@@ -76,100 +81,137 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
});
};
const handleMultiSubmit = () => {
innerProps.onSelectMany?.(selectedApps);
actions.closeModal();
};
return (
<>
<SelectGridLayout
search={search}
onSearchChange={setSearch}
placeholder={`${t("app.action.select.search")}...`}
ariaLabel={t("app.action.select.search")}
onSearchKeyDown={(event) => {
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
handleSelect(filteredApps[0]);
}
}}
<Stack gap="md">
<FloatingTip
opened={multiSelect}
showDelay={2_000}
dismissAfter={3_000}
transitionDuration={200}
closable={false}
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
>
{innerProps.withCreate && (
<CatalogItem
height={selectGridCardHeight}
label={t("app.action.create.title")}
status={t("app.action.create.action")}
onSelect={handleAddNewApp}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<IconPlus size={22} style={{ flexShrink: 0, marginTop: 2 }} />
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{t("app.action.create.title")}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{t("app.action.create.description")}
</Text>
<Text size="xs" c="blue" fw={500} mt="auto">
{t("app.action.create.action")}
</Text>
</Stack>
</CatalogItem>
)}
{t("tips.multiSelectApps")}
</FloatingTip>
{filteredApps.map((app) => (
<CatalogItem
key={app.id}
height={selectGridCardHeight}
label={app.name}
status={
multiSelect
? selectedAppIds.has(app.id)
? t("app.action.select.selected")
: t("app.action.select.toggle")
: t("app.action.select.action", { app: app.name })
{/* Top Search Input */}
<Stack gap={6}>
<Input
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
placeholder={`${t("app.action.select.search")}...`}
aria-label={t("app.action.select.search")}
data-autofocus
onKeyDown={(event) => {
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
handleSelect(filteredApps[0]);
}
selected={multiSelect ? selectedAppIds.has(app.id) : undefined}
onSelect={() => handleSelect(app)}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<img src={app.iconUrl} alt={app.name} width={22} height={22} style={{ flexShrink: 0, marginTop: 2 }} />
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{app.name}
</Text>
{multiSelect && selectedAppIds.has(app.id) && (
<IconCheck size={18} color="var(--mantine-primary-color-filled)" />
)}
</Group>
<Tooltip label={app.description} multiline w={250} disabled={!app.description}>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{app.description ?? ""}
</Text>
</Tooltip>
<Text size="xs" c="blue" fw={500} mt="auto">
{multiSelect
? selectedAppIds.has(app.id)
? t("app.action.select.selected")
: t("app.action.select.toggle")
: t("app.action.select.action", { app: app.name })}
</Text>
</Stack>
</CatalogItem>
))}
}}
/>
</Stack>
{filteredApps.length === 0 && !isPending && (
<Center p="xl">
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
{multiSelect && (
<Group justify="flex-end" mt="md">
<Button leftSection={<IconApps size={16} />} disabled={selectedAppIds.size === 0} onClick={handleSubmit}>
{t("app.action.select.multiple", { count: String(selectedAppIds.size) })}
</Button>
</Group>
{/* Scrollable Container with App Cards */}
<ScrollArea.Autosize mah="70vh" offsetScrollbars>
<Stack gap="md" pt="xs" pr="xs" px={4}>
<SimpleGrid cols={selectGridCols} spacing="sm">
{innerProps.withCreate && (
<SelectableCard
onClick={handleAddNewApp}
style={{ borderStyle: "dashed" }}
icon={
<ThemeIcon variant="light" color="primaryColor" size={34} radius="md">
<IconPlus size={20} />
</ThemeIcon>
}
title={t("app.action.create.title")}
description={t("app.action.create.description")}
footerLeft={
<Text size="xs" c="dimmed">
{t("app.action.select.customApplication")}
</Text>
}
/>
)}
{filteredApps.map((app) => (
<AppCard
key={app.id}
app={app}
isSelected={selectedAppIds.has(app.id)}
multiSelect={multiSelect}
onSelect={handleSelect}
/>
))}
</SimpleGrid>
{filteredApps.length === 0 && !isPending && (
<Center p="xl">
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
</Center>
)}
</Stack>
</ScrollArea.Autosize>
{/* Multi-Select Action Footer */}
{multiSelect && selectedApps.length > 0 && (
<Paper withBorder p="xs" radius="md" bg="light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8))">
<Group justify="space-between" align="center">
<Text size="sm" fw={600}>
{t("app.action.select.appsSelected", { count: selectedApps.length })}
</Text>
<Group gap="xs">
<Button variant="default" size="xs" onClick={() => setSelectedAppIds(new Set())}>
{t("common.action.discard")}
</Button>
<Button color="primaryColor" size="xs" onClick={handleMultiSubmit}>
{t("common.action.add")} ({selectedApps.length})
</Button>
</Group>
</Group>
</Paper>
)}
</>
</Stack>
);
}).withOptions({
defaultTitle: (t) => t("app.action.select.title"),
size: modalSizeSelect,
});
// =========================================================================
// AppCard: Variant 3 (Dashboard Inset) with Medium Title & Large App Icon
// =========================================================================
const AppCard = ({
app,
isSelected,
multiSelect,
onSelect,
}: {
app: SelectableApp;
isSelected: boolean;
multiSelect: boolean;
onSelect: (app: SelectableApp, event?: React.MouseEvent) => void;
}) => {
const t = useI18n();
return (
<SelectableCard
onClick={(event) => onSelect(app, event)}
aria-label={app.name}
selected={isSelected}
icon={<Image src={app.iconUrl} alt={app.name} w={28} h={28} fit="contain" style={{ flexShrink: 0 }} />}
title={app.name}
description={app.description}
footerLeft={
<Text size="xs" c="dimmed">
{multiSelect && isSelected ? t("app.action.select.selected") : t("app.action.select.application")}
</Text>
}
/>
);
};
@@ -5,7 +5,7 @@ import { clientApi } from "@homarr/api/client";
import { AppForm } from "@homarr/forms-collection";
import { createModal, modalSizeForm } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app";
interface QuickAddAppModalProps {
@@ -13,13 +13,14 @@ interface QuickAddAppModalProps {
}
export const QuickAddAppModal = createModal<QuickAddAppModalProps>(({ actions, innerProps }) => {
const tScoped = useScopedI18n("app.page.create.notification");
const t = useI18n();
const tScoped = useI18n("app.page.create.notification");
const tCommon = useI18n("common");
const tBoard = useI18n("board");
const { mutate, isPending } = clientApi.app.create.useMutation({
onError: () => {
showErrorNotification({
title: tScoped("error.title"),
title: tCommon("notification.create.error"),
message: tScoped("error.message"),
});
},
@@ -29,7 +30,7 @@ export const QuickAddAppModal = createModal<QuickAddAppModalProps>(({ actions, i
mutate(values, {
onSuccess(app) {
showSuccessNotification({
title: tScoped("success.title"),
title: tCommon("notification.create.success"),
message: tScoped("success.message"),
});
@@ -42,7 +43,7 @@ export const QuickAddAppModal = createModal<QuickAddAppModalProps>(({ actions, i
return (
<AppForm
buttonLabels={{
submit: t("board.action.quickCreateApp.modal.createAndUse"),
submit: tBoard("action.quickCreateApp.modal.createAndUse"),
submitAndCreateAnother: undefined,
}}
showBackToOverview={false}
@@ -12,7 +12,8 @@ import { useI18n } from "@homarr/translation/client";
import { boardColumnCountSchema, boardCreateSchema, boardNameSchema } from "@homarr/validation/board";
export const AddBoardModal = createModal(({ actions }) => {
const t = useI18n();
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const router = useRouter();
const form = useZodForm(boardCreateSchema, {
mode: "controlled",
@@ -39,8 +40,8 @@ export const AddBoardModal = createModal(({ actions }) => {
onSuccess: (result) => {
actions.closeModal();
showSuccessNotification({
title: t("board.action.create.notification.success.title"),
message: t("board.action.create.notification.success.message", { name: values.name }),
title: tBoard("action.create.notification.success.title"),
message: tBoard("action.create.notification.success.message", { name: values.name }),
});
const name = encodeURIComponent(result.name);
const layoutId = encodeURIComponent(result.layoutId);
@@ -48,8 +49,8 @@ export const AddBoardModal = createModal(({ actions }) => {
},
onError() {
showErrorNotification({
title: t("board.action.create.notification.error.title"),
message: t("board.action.create.notification.error.message", { name: values.name }),
title: tBoard("action.create.notification.error.title"),
message: tBoard("action.create.notification.error.message", { name: values.name }),
});
},
});
@@ -57,7 +58,7 @@ export const AddBoardModal = createModal(({ actions }) => {
>
<Stack>
<TextInput
label={t("board.field.name.label")}
label={tCommon("field.name")}
data-autofocus
{...form.getInputProps("name")}
description={
@@ -71,7 +72,7 @@ export const AddBoardModal = createModal(({ actions }) => {
) : null
}
/>
<InputWrapper label={t("board.field.columnCount.label")} {...form.getInputProps("columnCount")}>
<InputWrapper label={tBoard("field.columnCount.label")} {...form.getInputProps("columnCount")}>
<Slider
min={boardColumnCountSchema.minValue ?? undefined}
max={boardColumnCountSchema.maxValue ?? undefined}
@@ -81,17 +82,17 @@ export const AddBoardModal = createModal(({ actions }) => {
</InputWrapper>
<Switch
label={t("board.field.isPublic.label")}
description={t("board.field.isPublic.description")}
label={tBoard("field.isPublic.label")}
description={tBoard("field.isPublic.description")}
{...form.getInputProps("isPublic")}
/>
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{t("common.action.create")}
{tCommon("action.create")}
</Button>
</Group>
</Stack>
@@ -102,7 +103,8 @@ export const AddBoardModal = createModal(({ actions }) => {
});
export const useBoardNameStatus = (name: string) => {
const t = useI18n();
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const [debouncedName] = useDebouncedValue(name, 250);
const { data: boardExists, isLoading } = clientApi.board.exists.useQuery(debouncedName, {
enabled: boardNameSchema.safeParse(debouncedName).success,
@@ -115,19 +117,19 @@ export const useBoardNameStatus = (name: string) => {
? undefined
: isLoading
? {
label: t("board.action.create.availability.checking"),
label: tBoard("action.create.availability.checking"),
}
: boardExists === undefined
? undefined
: boardExists
? {
icon: IconAlertTriangle,
label: t("common.zod.errors.custom.boardAlreadyExists"), // The board ${debouncedName} already exists
label: tCommon("zod.errors.custom.boardAlreadyExists"), // The board ${debouncedName} already exists
color: "red",
}
: {
icon: IconCircleCheck,
label: t("board.action.create.availability.available", { name: debouncedName }),
label: tBoard("action.create.availability.available", { name: debouncedName }),
color: "green",
},
};
@@ -18,7 +18,8 @@ interface InnerProps {
}
export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const form = useZodForm(boardDuplicateSchema.omit({ id: true }), {
mode: "controlled",
initialValues: {
@@ -46,14 +47,14 @@ export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProp
onSuccess() {
actions.closeModal();
showSuccessNotification({
title: t("board.action.duplicate.notification.success.title"),
message: t("board.action.duplicate.notification.success.message"),
title: tBoard("action.duplicate.notification.success.title"),
message: tBoard("action.duplicate.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("board.action.duplicate.notification.error.title"),
message: t("board.action.duplicate.notification.error.message"),
title: tBoard("action.duplicate.notification.error.title"),
message: tBoard("action.duplicate.notification.error.message"),
});
},
},
@@ -62,11 +63,11 @@ export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProp
>
<Stack>
<Text size="sm" c="gray.6">
{t("board.action.duplicate.message", { name: innerProps.board.name })}
{tBoard("action.duplicate.message", { name: innerProps.board.name })}
</Text>
<TextInput
label={t("board.field.name.label")}
label={tCommon("field.name")}
data-autofocus
{...form.getInputProps("name")}
description={
@@ -82,10 +83,10 @@ export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProp
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{t("common.action.create")}
{tCommon("action.create")}
</Button>
</Group>
</Stack>
@@ -7,11 +7,17 @@ describe("createDockerRemovalConfirmation", () => {
const remove = vi.fn();
const t = vi.fn((key: string, values?: Record<string, string>) => `${key}:${JSON.stringify(values)}`);
const confirmation = createDockerRemovalConfirmation([{ name: "sonarr" }, { name: "radarr" }], t as never, remove);
const confirmation = createDockerRemovalConfirmation(
[{ name: "sonarr" }, { name: "radarr" }],
t as never,
"Remove",
remove,
);
expect(remove).not.toHaveBeenCalled();
expect(confirmation.title).toContain('"count":"2"');
expect(confirmation.children).toContain('"names":"sonarr, radarr"');
expect(confirmation.confirmProps.children).toBe("Remove");
confirmation.onConfirm();
expect(remove).toHaveBeenCalledOnce();
@@ -3,7 +3,7 @@
import { useCallback } from "react";
import { useConfirmModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface DockerContainerName {
name: string;
@@ -11,7 +11,8 @@ interface DockerContainerName {
export const createDockerRemovalConfirmation = (
containers: DockerContainerName[],
t: ReturnType<typeof useScopedI18n<"docker.action.remove">>,
t: ReturnType<typeof useI18n<"docker.action.remove">>,
confirmLabel: string,
onConfirm: () => void | Promise<void>,
) => ({
title: t("confirmation.title", { count: String(containers.length) }),
@@ -19,18 +20,19 @@ export const createDockerRemovalConfirmation = (
count: String(containers.length),
names: containers.map(({ name }) => name).join(", "),
}),
confirmProps: { children: t("label"), color: "red.9" },
confirmProps: { children: confirmLabel, color: "red.9" },
onConfirm,
});
export const useDockerContainerRemovalConfirmation = () => {
const t = useScopedI18n("docker.action.remove");
const t = useI18n("docker.action.remove");
const commonT = useI18n("common.action");
const { openConfirmModal } = useConfirmModal();
return useCallback(
(containers: DockerContainerName[], onConfirm: () => void | Promise<void>) => {
openConfirmModal(createDockerRemovalConfirmation(containers, t, onConfirm));
openConfirmModal(createDockerRemovalConfirmation(containers, t, commonT("remove"), onConfirm));
},
[openConfirmModal, t],
[commonT, openConfirmModal, t],
);
};
@@ -9,7 +9,8 @@ import { useI18n } from "@homarr/translation/client";
import { groupCreateSchema } from "@homarr/validation/group";
export const AddGroupModal = createModal<void>(({ actions }) => {
const t = useI18n();
const tGroup = useI18n("group");
const tCommon = useI18n("common");
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
const form = useZodForm(groupCreateSchema, {
initialValues: {
@@ -25,27 +26,27 @@ export const AddGroupModal = createModal<void>(({ actions }) => {
actions.closeModal();
void revalidatePathActionAsync("/manage/users/groups");
showSuccessNotification({
title: t("common.notification.create.success"),
message: t("group.action.create.notification.success.message"),
title: tCommon("notification.create.success"),
message: tGroup("action.create.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("common.notification.create.error"),
message: t("group.action.create.notification.error.message"),
title: tCommon("notification.create.error"),
message: tGroup("action.create.notification.error.message"),
});
},
});
})}
>
<Stack>
<TextInput label={t("group.field.name")} data-autofocus {...form.getInputProps("name")} />
<TextInput label={tCommon("field.name")} data-autofocus {...form.getInputProps("name")} />
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
{tCommon("action.cancel")}
</Button>
<Button loading={isPending} type="submit">
{t("common.action.create")}
{tCommon("action.create")}
</Button>
</Group>
</Stack>
@@ -2,12 +2,13 @@ import { usePathname } from "next/navigation";
import { Button, CopyButton, Mark, Stack, Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { createModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
export const InviteCopyModal = createModal<RouterOutputs["invite"]["createInvite"]>(({ actions, innerProps }) => {
const t = useScopedI18n("management.page.user.invite");
const t = useI18n("management.page.user.invite");
const inviteUrl = useInviteUrl(innerProps);
return (
@@ -19,7 +20,7 @@ export const InviteCopyModal = createModal<RouterOutputs["invite"]["createInvite
</Text>
<Link href={createPath(innerProps)}>{t("action.copy.link")}</Link>
<Stack gap="xs">
<Text fw="bold">{t("field.id.label")}:</Text>
<Text fw="bold">{invariantTechnicalLabels.id}:</Text>
<Mark style={{ borderRadius: 4 }} color="gray" px={5}>
{innerProps.id}
</Mark>
@@ -6,7 +6,7 @@ import relativeTime from "dayjs/plugin/relativeTime";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal, useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { InviteCopyModal } from "./invite-copy-modal";
@@ -17,7 +17,7 @@ interface FormType {
}
export const InviteCreateModal = createModal<void>(({ actions }) => {
const tInvite = useScopedI18n("management.page.user.invite");
const tInvite = useI18n("management.page.user.invite");
const t = useI18n();
const { openModal } = useModalAction(InviteCopyModal);
+8 -2
View File
@@ -102,6 +102,13 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
content: presentationClasses.inspectorContent,
});
const handleClose = useCallback(() => {
const canClose = modal.reference.onCloseRequest ? modal.reference.onCloseRequest() : true;
if (canClose) {
handleCloseModal();
}
}, [handleCloseModal, modal.reference]);
return (
<Modal
key={modal.id}
@@ -123,7 +130,7 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
{...otherModalProps}
title={translateIfNecessary(t, modal.props.defaultTitle)}
opened={opened}
onClose={handleCloseModal}
onClose={handleClose}
>
{modal.reference.content}
</Modal>
@@ -144,7 +151,6 @@ export const useModalAction = <TModal extends ModalDefinition>(modal: TModal) =>
return {
openModal: (innerProps: inferInnerProps<TModal>, options: OpenModalOptions | void) => {
// void actually is undefined
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
context.openModalInner({ modal, innerProps, options: options ?? {} });
},
};
+11
View File
@@ -94,6 +94,8 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
const ModalContent = modal.modal.component;
const { innerProps, ...rest } = modal.props;
let closeInterceptor: (() => boolean) | null = null;
const FullModal = () => {
const context = useContext(ModalContext);
@@ -106,6 +108,9 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
innerProps={innerProps}
actions={{
closeModal: () => context.closeModal(modal.id),
setCloseInterceptor: (interceptor: (() => boolean) | null) => {
closeInterceptor = interceptor;
},
}}
/>
);
@@ -114,5 +119,11 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
return {
modalProps: rest,
content: <FullModal />,
onCloseRequest: () => {
if (closeInterceptor) {
return closeInterceptor();
}
return true;
},
};
};
+6 -4
View File
@@ -5,10 +5,12 @@ import type { stringOrTranslation } from "@homarr/translation";
import type { ModalPresentation } from "./modal-presentation";
export type ModalComponent<TInnerProps> = (props: {
actions: { closeModal: () => void };
innerProps: TInnerProps;
}) => ReactNode;
export interface ModalActions {
closeModal: () => void;
setCloseInterceptor?: (interceptor: (() => boolean) | null) => void;
}
export type ModalComponent<TInnerProps> = (props: { actions: ModalActions; innerProps: TInnerProps }) => ReactNode;
export type CreateModalOptions = Pick<
ModalOptions<unknown>,
+5 -5
View File
@@ -11,7 +11,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { groupCreateSchema } from "@homarr/validation/group";
import { userInitSchema } from "@homarr/validation/user";
@@ -41,7 +41,7 @@ export const AccountSetup = ({ environment }: OnboardingStudioProps) => {
};
const CredentialsSetup = () => {
const t = useScopedI18n("init.studio.account");
const t = useI18n("init.studio.account");
const [requiresSignIn, setRequiresSignIn] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const recoveryActionRef = useRef<HTMLAnchorElement>(null);
@@ -104,7 +104,7 @@ const CredentialsSetup = () => {
return (
<AccountShell title={t("title")} description={t("description")}>
<form onSubmit={form.onSubmit((values) => void submitAsync(values))}>
<form onSubmit={form.onSubmit((values) => submitAsync(values))}>
<Stack gap="md">
<TextInput label={t("username")} autoComplete="username" withAsterisk {...form.getInputProps("username")} />
<UserCreatePasswordFields
@@ -119,7 +119,7 @@ const CredentialsSetup = () => {
{submitError}
</Alert>
) : null}
<Button type="submit" size="md" loading={mutation.isPending} rightSection={<IconArrowRight size={18} />}>
<Button type="submit" size="md" loading={form.submitting} rightSection={<IconArrowRight size={18} />}>
{t("create")}
</Button>
</Stack>
@@ -129,7 +129,7 @@ const CredentialsSetup = () => {
};
const ExternalGroupSetup = () => {
const t = useScopedI18n("init.studio.externalGroup");
const t = useI18n("init.studio.externalGroup");
const [submitError, setSubmitError] = useState<string | null>(null);
const sounds = useOnboardingSounds();
const mutation = clientApi.group.createInitialExternalGroup.useMutation({
+2 -2
View File
@@ -6,7 +6,7 @@ import { IconArrowRight, IconBook2, IconSparkles, IconTool } from "@tabler/icons
import confetti from "canvas-confetti";
import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { OnboardingBackdrop } from "./onboarding-backdrop";
@@ -16,7 +16,7 @@ import { useOnboardingSounds } from "./use-onboarding-sounds";
import classes from "./onboarding-studio.module.css";
export const Finish = ({ environment }: OnboardingStudioProps) => {
const t = useScopedI18n("init.studio.finish");
const t = useI18n("init.studio.finish");
const sounds = useOnboardingSounds();
const primaryColor = environment.initialBoard?.primaryColor ?? "#fa5252";
const secondaryColor = environment.initialBoard?.secondaryColor ?? "#fd7e14";
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { Alert, Button, Center, Paper, Stack, Text, Title } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { OnboardingStudioProps } from "./types";
@@ -22,8 +22,8 @@ const getInitialClaimState = (accessState: ReturnType<typeof getOnboardingAccess
};
export const OnboardingStudio = (props: OnboardingStudioProps) => {
const t = useScopedI18n("init.studio.claim");
const tCommon = useScopedI18n("common.action");
const t = useI18n("init.studio.claim");
const tCommon = useI18n("common.action");
const accessState = getOnboardingAccessState(props.environment);
const [claimState, setClaimState] = useState<ClaimState>(() => getInitialClaimState(accessState));
const [claimError, setClaimError] = useState<string | null>(null);
@@ -5,7 +5,7 @@ import { useRef, useState } from "react";
import { FloatingIndicator, Stack, TextInput, UnstyledButton } from "@mantine/core";
import type { UrlTemplateMode } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { useOnboardingSounds } from "./use-onboarding-sounds";
import classes from "./onboarding-studio.module.css";
@@ -78,7 +78,7 @@ export const ServiceUrlTemplate = ({
readOnly,
required,
}: ServiceUrlTemplateProps) => {
const t = useScopedI18n("common.serviceUrlTemplate");
const t = useI18n("common.serviceUrlTemplate");
return (
<Stack gap="sm">
+18 -17
View File
@@ -90,7 +90,7 @@ import {
} from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
import type { SupportedLanguage } from "@homarr/translation";
import { useCurrentLocale, useScopedI18n } from "@homarr/translation/client";
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import { BoardColorInput, ColorSchemeCombobox, IntegrationAvatar, LanguageCombobox, Link } from "@homarr/ui";
import { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid";
@@ -154,8 +154,8 @@ const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = [];
const emptyDiscoveredApps: DockerDiscoveryData["apps"] = [];
export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingStudioProps) => {
const t = useScopedI18n("init.studio");
const tCommon = useScopedI18n("common.action");
const t = useI18n("init.studio");
const tCommon = useI18n("common.action");
const currentLocale = useCurrentLocale();
const { colorScheme, setColorScheme } = useMantineColorScheme();
const sounds = useOnboardingSounds();
@@ -781,7 +781,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
rightSection={<IconArrowRight size={16} />}
onClick={continueOnboarding}
>
{t("continue")}
{tCommon("continue")}
</Button>
</Group>
)}
@@ -887,7 +887,7 @@ const SectionHeading = ({
);
const Essentials = (props: StudioSectionProps) => {
const t = useScopedI18n("init.studio.essentials");
const t = useI18n("init.studio.essentials");
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => setIsHydrated(true), []);
return (
@@ -936,7 +936,7 @@ const Essentials = (props: StudioSectionProps) => {
};
const Discovery = (props: StudioSectionProps) => {
const t = useScopedI18n("init.studio.discover");
const t = useI18n("init.studio.discover");
const dockerStatus = !props.environment.dockerConfigured
? "disabled"
: props.docker.isPending
@@ -1064,7 +1064,7 @@ const CapabilityRow = ({
detail: string;
action?: ReactNode;
}) => {
const t = useScopedI18n("init.studio.discover.status");
const t = useI18n("init.studio.discover.status");
const color =
status === "available"
? "green"
@@ -1107,7 +1107,7 @@ const CapabilityRow = ({
};
const Connections = (props: StudioSectionProps) => {
const t = useScopedI18n("init.studio.connect");
const t = useI18n("init.studio.connect");
return (
<Stack gap="xl">
<SectionHeading headingRef={props.headingRef} title={t("title")} description={t("description")} />
@@ -1248,7 +1248,8 @@ const IntegrationEditor = ({
update: (patch: Partial<IntegrationDraft>) => void;
selectSecretOption: (option: number) => void;
}) => {
const t = useScopedI18n("init.studio.connect");
const t = useI18n("init.studio.connect");
const tCommon = useI18n("common.field");
const sounds = useOnboardingSounds();
const options = getAllSecretKindOptions(draft.kind);
const apiKeyUrl = getIntegrationApiKeyUrl(draft.url, draft.kind);
@@ -1313,7 +1314,7 @@ const IntegrationEditor = ({
<Stack>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput
label={t("name")}
label={tCommon("name")}
value={draft.name}
onChange={(event) => updateAndResetTest({ name: event.currentTarget.value })}
/>
@@ -1415,7 +1416,7 @@ const formatSecretKind = (kind: IntegrationSecretKind) =>
kind.replace(/([A-Z])/g, " $1").replace(/^./, (letter) => letter.toUpperCase());
const BoardBuilder = (props: StudioSectionProps) => {
const t = useScopedI18n("init.studio.board");
const t = useI18n("init.studio.board");
const sounds = useOnboardingSounds();
const validationErrors = getBoardValidationErrors({
attempted: props.boardValidationAttempted,
@@ -1634,7 +1635,7 @@ const BoardPreview = ({
};
const Extensions = (props: StudioSectionProps) => {
const t = useScopedI18n("init.studio.extend");
const t = useI18n("init.studio.extend");
return (
<Stack gap={0}>
<Title id="studio-section-title" ref={props.headingRef} order={2} tabIndex={-1} mb="xs">
@@ -1649,7 +1650,7 @@ const Extensions = (props: StudioSectionProps) => {
{t("assistant")}
</Tabs.Tab>
<Tabs.Tab value="mcp" leftSection={<IconCode size={16} />}>
{t("mcp")}
MCP
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="workshop" pt="xs">
@@ -1673,7 +1674,7 @@ const AssistantSetup = ({
environment: StudioSectionProps["environment"];
configuration: ReactNode;
}) => {
const t = useScopedI18n("init.studio.extend.assistantSetup");
const t = useI18n("init.studio.extend.assistantSetup");
if (!environment.canConfigurePrivileged) {
return (
<Alert icon={<IconKey size={18} />} title={t("loginTitle")}>
@@ -1697,7 +1698,7 @@ const WorkshopSetup = ({
environment: StudioSectionProps["environment"];
runtimeCapabilities: StudioSectionProps["runtimeCapabilities"];
}) => {
const t = useScopedI18n("init.studio.extend.workshopSetup");
const t = useI18n("init.studio.extend.workshopSetup");
const status = runtimeCapabilities.isPending
? "checking"
: runtimeCapabilities.data?.workshop.status === "available"
@@ -1755,7 +1756,7 @@ const WorkshopFeature = ({ title, description }: { title: string; description: s
);
const McpSetup = ({ environment }: { environment: StudioSectionProps["environment"] }) => {
const t = useScopedI18n("init.studio.extend.mcpSetup");
const t = useI18n("init.studio.extend.mcpSetup");
const [apiKey, setApiKey] = useState<string | null>(null);
const sounds = useOnboardingSounds();
const createKey = clientApi.apiKeys.create.useMutation({
@@ -1861,7 +1862,7 @@ const McpSetup = ({ environment }: { environment: StudioSectionProps["environmen
};
const Review = (props: StudioSectionProps) => {
const t = useScopedI18n("init.studio.review");
const t = useI18n("init.studio.review");
const themeLabel = {
auto: t("themeAuto"),
light: t("themeLight"),
+3 -3
View File
@@ -7,7 +7,7 @@ import { IconArrowRight, IconDatabaseImport } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { OnboardingBackdrop } from "./onboarding-backdrop";
import { OnboardingWordmark } from "./onboarding-wordmark";
@@ -16,8 +16,8 @@ import { useOnboardingSounds, useWelcomeSound } from "./use-onboarding-sounds";
import classes from "./onboarding-studio.module.css";
export const Welcome = ({ environment, sqliteRestore }: OnboardingStudioProps) => {
const t = useScopedI18n("init.studio.welcome");
const claimT = useScopedI18n("init.studio.claim");
const t = useI18n("init.studio.welcome");
const claimT = useI18n("init.studio.claim");
const [showRestore, setShowRestore] = useState(false);
const [claimPending, setClaimPending] = useState(false);
const [claimError, setClaimError] = useState<string | null>(null);
@@ -74,7 +74,7 @@ const appChildrenOptions = createChildrenOptions<App>({
export const appsSearchGroup = createGroup<App>({
keyPath: "id",
title: (t) => t("search.mode.appIntegrationBoard.group.app.title"),
title: (t) => t("common.entity.apps"),
Component: (app) => (
<Group px="md" py="sm">
<Avatar
@@ -125,7 +125,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
export const boardsSearchGroup = createGroup<Board>({
keyPath: "id",
title: "Boards",
title: (t) => t("common.entity.boards"),
Component: (board) => (
<Group px="md" py="sm">
{board.logoImageUrl ? (
@@ -9,7 +9,7 @@ import { interaction } from "../../lib/interaction";
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
keyPath: "id",
title: (t) => t("search.mode.appIntegrationBoard.group.integration.title"),
title: (t) => t("common.entity.integrations"),
Component: (integration) => (
<Group px="md" py="sm">
<IntegrationAvatar size="sm" kind={integration.kind} />
@@ -2,13 +2,13 @@ import type { ReactNode } from "react";
import { Center, Group, Loader, Stack, Text } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { interaction } from "../../../../lib/interaction";
export const PreferenceDetailHeader = ({ titleKey }: { titleKey: string }) => {
const tOption = useScopedI18n("search.mode.command.group.preferences.option");
const t = useScopedI18n("search.mode.command.group.preferences");
const tOption = useI18n("search.mode.command.group.preferences.option");
const t = useI18n("search.mode.command.group.preferences");
return (
<Stack mx="md" my="sm" gap="xs">
@@ -10,14 +10,18 @@ import { useSession } from "@homarr/auth/client";
import type { ColorScheme } from "@homarr/definitions";
import { visiblePreferenceDefinitions } from "@homarr/settings";
import { localeConfigurations } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createChildrenOptions } from "../../../../lib/children";
import type { ChildrenAction } from "../../../../lib/children";
import { interaction } from "../../../../lib/interaction";
import { useUserPreferences } from "../../../../preferences/use-user-preference";
import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../../../preferences/preference-registry";
import {
preferenceChildrenOptionsByKey,
preferenceIcons,
userFieldPreferenceLabels,
} from "../../../../preferences/preference-registry";
dayjs.extend(localeData);
@@ -65,8 +69,9 @@ const LinkRow = ({ label, Icon }: { label: string; Icon: TablerIcon }) => (
const SELECT_KINDS = new Set(["select", "searchEngine", "board"]);
const useSettingsActions = (_: Record<string, unknown>, query: string): SettingsAction[] => {
const t = useScopedI18n("search.mode.command.group.preferences.option");
const tColorScheme = useScopedI18n("common.colorScheme.options");
const t = useI18n("search.mode.command.group.preferences.option");
const tUserField = useI18n("user.field");
const tColorScheme = useI18n("common.colorScheme.options");
const { data: session } = useSession();
const isAuthenticated = Boolean(session?.user);
const normalizedQuery = query.trim().toLowerCase();
@@ -95,7 +100,8 @@ const useSettingsActions = (_: Record<string, unknown>, query: string): Settings
return visiblePreferenceDefinitions(isAuthenticated)
.map((definition): SettingsAction | null => {
const label = t(`${definition.key}.label` as never);
const userFieldKey = userFieldPreferenceLabels[definition.key];
const label = userFieldKey ? tUserField(`${userFieldKey}.label` as never) : t(`${definition.key}.label` as never);
const Icon = preferenceIcons[definition.key];
const matchesSearch = [label, ...definition.aliases].some((v) => v.toLowerCase().includes(normalizedQuery));
@@ -143,7 +149,7 @@ const useSettingsActions = (_: Record<string, unknown>, query: string): Settings
export const settingsChildrenOptions = createChildrenOptions<Record<string, unknown>>({
useActions: useSettingsActions,
DetailComponent: () => {
const t = useScopedI18n("search.mode.command.group.preferences");
const t = useI18n("search.mode.command.group.preferences");
return (
<Stack mx="md" my="sm" gap="xs">
<Text>{t("title")}</Text>
@@ -14,7 +14,7 @@ import { useSession } from "@homarr/auth/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal, AddGroupModal, InviteCreateModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createGroup } from "../../lib/group";
@@ -48,7 +48,7 @@ export const globalCommandGroup = createGroup<Command>({
return option.name.toLowerCase().includes(query.toLowerCase());
},
useOptions() {
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
const tOption = useI18n("search.mode.command.group.globalCommand.option");
const { data: session } = useSession();
const pathname = usePathname();
const router = useRouter();
@@ -10,7 +10,7 @@ import { getIntegrationKindsByCategory, getIntegrationName } from "@homarr/defin
import { useModalAction } from "@homarr/modals";
import { RequestMediaModal } from "@homarr/modals-collection";
import { useSettings } from "@homarr/settings";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children";
import { createGroup } from "../../lib/group";
@@ -135,7 +135,7 @@ export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestCh
(option.result.type === "movie" && option.result.inLibrary) ||
option.integration.permissions?.hasInteractAccess === false,
Component(option) {
const t = useScopedI18n("search.mode.media");
const t = useI18n("search.mode.media");
return (
<Group mx="md" my="sm" wrap="nowrap">
<IconDownload stroke={1.5} />
@@ -164,7 +164,7 @@ export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestCh
{
key: "open",
Component({ integration }) {
const tChildren = useScopedI18n("search.mode.media");
const tChildren = useI18n("search.mode.media");
return (
<Group mx="md" my="sm" wrap="nowrap">
<IconSearch stroke={1.5} />
@@ -220,7 +220,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
{
key: "search",
Component: ({ name }) => {
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
const tChildren = useI18n("search.mode.external.group.searchEngine.children");
return (
<Group mx="md" my="sm">
@@ -265,7 +265,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
}));
},
DetailComponent({ options }) {
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
const tChildren = useI18n("search.mode.external.group.searchEngine.children");
return (
<Stack mx="md" my="sm">
<Text>{options.type === "generic" ? tChildren("detail.title") : tChildren("searchResults.title")}</Text>
@@ -298,7 +298,7 @@ const buildSearchUrl = (template: string, query: string) => {
export const searchEnginesSearchGroups = createGroup<ExternalOption>({
keyPath: "key",
title: (t) => t("search.mode.external.group.searchEngine.title"),
title: (t) => t("common.entity.searchEngines"),
Component: (option) => {
if (option.kind === "hint") {
return (
@@ -403,7 +403,7 @@ export const searchEnginesSearchGroups = createGroup<ExternalOption>({
return { type: "none" };
},
useQueryOptions(query) {
const tExternal = useScopedI18n("search.mode.external.group.searchEngine");
const tExternal = useI18n("search.mode.external.group.searchEngine");
const { ddgBangs } = useSettings();
const { bangToken, searchText, locked } = parseBangQuery(query);
const [debouncedBangToken] = useDebouncedValue(bangToken, 150);
@@ -10,7 +10,7 @@ import {
import { useSession } from "@homarr/auth/client";
import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createGroup } from "../../lib/group";
@@ -33,7 +33,8 @@ type QuickLinkOption = {
export const useHomeEmptyGroups = () => {
const { data: session } = useSession();
const tPages = useScopedI18n("search.mode.page.group.page.option");
const tPages = useI18n("search.mode.page.group.page.option");
const tHelp = useI18n("management.navbar.items.help.items");
const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, mediaMode, commandMode, pageMode];
if (session?.user.permissions.includes("admin")) {
@@ -88,7 +89,7 @@ export const useHomeEmptyGroups = () => {
title: (t) => t("search.mode.help.group.mode.title"),
options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })),
Component: ({ modeKey, character }) => {
const t = useScopedI18n(`search.mode.${modeKey}`);
const t = useI18n(`search.mode.${modeKey}`);
return (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
@@ -102,25 +103,23 @@ export const useHomeEmptyGroups = () => {
}),
createGroup({
keyPath: "href",
title: (t) => t("search.mode.help.group.help.title"),
title: (t) => t("management.navbar.items.help.label"),
useOptions() {
const t = useScopedI18n("search.mode.help.group.help.option");
return [
{
href: createDocumentationLink("/docs/getting-started"),
icon: IconBook2,
label: t("documentation.label"),
label: tHelp("documentation"),
},
{
href: "https://github.com/homarr-labs/homarr/issues/new/choose",
icon: IconBrandGithub,
label: t("submitIssue.label"),
label: tHelp("submitIssue"),
},
{
href: "https://discord.com/invite/aCsmEV5RgA",
icon: IconBrandDiscord,
label: t("discord.label"),
label: tHelp("discord"),
},
];
},
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import type { MediaAvailability } from "@homarr/integrations/types";
import { mediaAvailabilityConfiguration } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import { mediaRequestSearchScopeAtom } from "../../spotlight-store";
@@ -37,7 +37,7 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
keyPath: "key",
title: (t) => t("search.mode.media.group.title"),
Component(option) {
const tMedia = useScopedI18n("search.mode.media");
const tMedia = useI18n("search.mode.media");
if (option.kind !== "result") {
return (
@@ -96,7 +96,7 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
return useMediaRequestSearchInteraction(option.result.integration, option.result);
},
useQueryOptions(query) {
const tMedia = useScopedI18n("search.mode.media");
const tMedia = useI18n("search.mode.media");
const scope = useAtomValue(mediaRequestSearchScopeAtom);
const [debouncedQuery] = useDebouncedValue(query.trim(), 150);
const targetsQuery = clientApi.integration.mediaRequestSearchTargets.useQuery();
@@ -17,7 +17,7 @@ import {
} from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createGroup } from "../../lib/group";
@@ -59,7 +59,7 @@ export const pagesSearchGroup = createGroup<{
},
useOptions() {
const { data: session } = useSession();
const t = useScopedI18n("search.mode.page.group.page.option");
const t = useI18n("search.mode.page.group.page.option");
const managePages = [
{
@@ -3,14 +3,21 @@ import { IconSettings } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { colorSchemes } from "@homarr/definitions";
import type { UserPreferenceKey } from "@homarr/settings";
import { visiblePreferenceDefinitions } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../preferences/preference-registry";
import {
preferenceChildrenOptionsByKey,
preferenceIcons,
userFieldPreferenceLabels,
} from "../../preferences/preference-registry";
import { settingsChildrenOptions } from "../command/children/preferences/settings-children";
type PreferencesGroupOption = { key: "settings"; preferenceKey: null } | { key: string; preferenceKey: string };
type PreferencesGroupOption =
| { key: "settings"; preferenceKey: null }
| { key: UserPreferenceKey; preferenceKey: UserPreferenceKey };
const settingsOption: PreferencesGroupOption = { key: "settings", preferenceKey: null };
@@ -19,7 +26,8 @@ export const preferencesGroup = createGroup<PreferencesGroupOption>({
title: (t) => t("search.mode.command.group.preferences.title"),
useOptions(query) {
const t = useI18n();
const tColorScheme = useScopedI18n("common.colorScheme.options");
const tColorScheme = useI18n("common.colorScheme.options");
const tUserField = useI18n("user.field");
const { data: session } = useSession();
const definitions = visiblePreferenceDefinitions(Boolean(session?.user));
const q = query.trim().toLowerCase();
@@ -28,7 +36,10 @@ export const preferencesGroup = createGroup<PreferencesGroupOption>({
const matching = definitions.flatMap((def): PreferencesGroupOption[] => {
if (!preferenceChildrenOptionsByKey[def.key]) return [];
const label = t(`search.mode.command.group.preferences.option.${def.key}.label` as never);
const userFieldKey = userFieldPreferenceLabels[def.key];
const label = userFieldKey
? tUserField(`${userFieldKey}.label` as never)
: t(`search.mode.command.group.preferences.option.${def.key}.label` as never);
const searchValues = [
label,
...def.aliases,
@@ -47,12 +58,19 @@ export const preferencesGroup = createGroup<PreferencesGroupOption>({
return allSearchValues.some((v) => v.toLowerCase().includes(q)) ? [settingsOption] : [];
},
Component: (option) => {
const t = useScopedI18n("search.mode.command.group.preferences");
const tOption = useScopedI18n("search.mode.command.group.preferences.option");
const t = useI18n("search.mode.command.group.preferences");
const tOption = useI18n("search.mode.command.group.preferences.option");
const tUserField = useI18n("user.field");
const Icon = option.preferenceKey
? preferenceIcons[option.preferenceKey as keyof typeof preferenceIcons]
: IconSettings;
const label = option.preferenceKey ? tOption(`${option.preferenceKey}.label` as never) : t("title");
let label = t("title");
if (option.preferenceKey) {
const userFieldKey = userFieldPreferenceLabels[option.preferenceKey];
label = userFieldKey
? tUserField(`${userFieldKey}.label` as never)
: tOption(`${option.preferenceKey}.label` as never);
}
return (
<Group px="md" py="sm" wrap="nowrap" gap="sm">
@@ -70,7 +70,7 @@ const groupChildrenOptions = createChildrenOptions<Group>({
export const groupsSearchGroup = createGroup<Group>({
keyPath: "id",
title: "Groups",
title: (t) => t("common.entity.groups"),
Component: ({ name }) => (
<Group px="md" py="sm">
<Text>{name}</Text>
@@ -48,7 +48,7 @@ const userChildrenOptions = createChildrenOptions<User>({
export const usersSearchGroup = createGroup<User>({
keyPath: "id",
title: (t) => t("search.mode.userGroup.group.user.title"),
title: (t) => t("common.entity.users"),
Component: (user) => (
<Group px="md" py="sm">
<UserAvatar user={user} size="sm" />
@@ -20,7 +20,7 @@ import localeData from "dayjs/plugin/localeData";
import { clientApi } from "@homarr/api/client";
import { colorSchemes } from "@homarr/definitions";
import type { UserPreferenceKey } from "@homarr/settings";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createChildrenOptions } from "../lib/children";
@@ -37,6 +37,21 @@ dayjs.extend(localeData);
type ChildrenFactory = ReturnType<typeof createChildrenOptions<Record<string, unknown>>>;
export type UserFieldPreferenceKey =
| "defaultSearchEngine"
| "openSearchInNewTab"
| "ddgBangs"
| "pingIconsEnabled"
| "enableRightClickOnWidgets";
export const userFieldPreferenceLabels: Partial<Record<UserPreferenceKey, UserFieldPreferenceKey>> = {
defaultSearchEngineId: "defaultSearchEngine",
openSearchInNewTab: "openSearchInNewTab",
ddgBangs: "ddgBangs",
pingIconsEnabled: "pingIconsEnabled",
enableRightClickOnWidgets: "enableRightClickOnWidgets",
} satisfies Partial<Record<UserPreferenceKey, UserFieldPreferenceKey>>;
export const preferenceIcons: Record<UserPreferenceKey, TablerIcon> = {
colorScheme: IconDeviceDesktop,
locale: IconLanguage,
@@ -82,7 +97,7 @@ const createAsyncSelectableChildren = <TItem,>(
useActions: (_, query) => {
const { value, setValue, isPending } = useUserPreference(preferenceKey);
const result = useQuery();
const t = useScopedI18n("search.mode.command.group.preferences.option");
const t = useI18n("search.mode.command.group.preferences.option");
if (result.isLoading) return [createLoadingPreferenceAction()];
@@ -103,7 +118,7 @@ const firstDayOfWeekOptions: DayOfWeek[] = [1, 6, 0];
export const preferenceChildrenOptionsByKey: Partial<Record<UserPreferenceKey, ChildrenFactory>> = {
colorScheme: createStaticChildren("colorScheme", () => {
const t = useScopedI18n("common.colorScheme.options");
const t = useI18n("common.colorScheme.options");
return colorSchemes.map((s) => ({ key: s, label: t(s), value: s }));
}),
locale: languageChildrenOptions,
-3
View File
@@ -18,7 +18,4 @@ declare module "next-intl" {
// Keep these as direct exports so Turbopack and webpack can statically inspect
// the client package's public surface.
export const useI18n = useTranslations;
export const useScopedI18n = useTranslations;
export const useI18nMessages = () => useMessages();
export { useTranslations };
File diff suppressed because it is too large. Load diff
+1 -4
View File
@@ -10,9 +10,6 @@ declare module "next-intl" {
}
}
export const { getI18n, getScopedI18n } = {
getI18n: getTranslations,
getScopedI18n: getTranslations,
};
export const getI18n = getTranslations;
export { getMessages as getI18nMessages } from "next-intl/server";
+2 -2
View File
@@ -2,13 +2,13 @@ import type { NamespaceKeys, NestedKeyOf } from "next-intl";
import type { RemoveReadonly } from "@homarr/common/types";
import type { useI18n, useScopedI18n } from "./client";
import type { useI18n } from "./client";
import type enTranslation from "./lang/en.json";
export type TranslationFunction = ReturnType<typeof useI18n<never>>;
export type ScopedTranslationFunction<
NestedKey extends NamespaceKeys<IntlMessages, NestedKeyOf<IntlMessages>> = never,
> = ReturnType<typeof useScopedI18n<NestedKey>>;
> = ReturnType<typeof useI18n<NestedKey>>;
export type TranslationObject = typeof enTranslation;
export type stringOrTranslation = string | ((t: TranslationFunction) => string);
+3 -3
View File
@@ -3,17 +3,17 @@
import type { BadgeProps } from "@mantine/core";
import { Badge } from "@mantine/core";
import { useTranslations } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface BetaBadgeProps {
size: BadgeProps["size"];
}
export const BetaBadge = ({ size }: BetaBadgeProps) => {
const t = useTranslations();
const t = useI18n("common");
return (
<Badge size={size} color="green" variant="outline">
{t("common.beta")}
{t("beta")}
</Badge>
);
};
@@ -6,7 +6,7 @@ import { IconCheck, IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-r
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
const schemeIcons: Record<ColorScheme, React.ElementType> = {
auto: IconDeviceDesktop,
@@ -22,7 +22,7 @@ interface ColorSchemeComboboxProps {
}
export const ColorSchemeCombobox = ({ label, value, onChange, width }: ColorSchemeComboboxProps) => {
const tOptions = useScopedI18n("common.colorScheme.options");
const tOptions = useI18n("common.colorScheme.options");
const combobox = useCombobox({ onDropdownClose: () => combobox.resetSelectedOption() });
return (
@@ -0,0 +1,17 @@
.root {
position: fixed;
inset-inline: var(--mantine-spacing-md);
bottom: var(--floating-tip-bottom);
z-index: var(--floating-tip-z-index);
display: flex;
justify-content: center;
pointer-events: none;
}
.alert {
width: fit-content;
max-width: min(var(--floating-tip-max-width), calc(100vw - 2 * var(--mantine-spacing-md)));
pointer-events: auto;
box-shadow: var(--mantine-shadow-xl);
backdrop-filter: blur(12px);
}
@@ -0,0 +1,5 @@
declare const classes: {
readonly root: string;
readonly alert: string;
};
export default classes;
@@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FloatingTip } from "./floating-tip";
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
vi.useFakeTimers();
window.localStorage.clear();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
document.body.querySelectorAll("[data-portal]").forEach((portal) => portal.remove());
vi.useRealTimers();
});
describe("FloatingTip", () => {
it("shows after the configured delay and auto-dismisses", async () => {
await act(async () =>
root.render(
<MantineProvider>
<FloatingTip opened showDelay={2_000} dismissAfter={3_000} transitionDuration={0}>
Select several apps
</FloatingTip>
</MantineProvider>,
),
);
expect(document.body.textContent).not.toContain("Select several apps");
await act(async () => vi.advanceTimersByTime(2_000));
expect(document.body.textContent).toContain("Select several apps");
await act(async () => vi.advanceTimersByTime(3_000));
expect(document.body.textContent).not.toContain("Select several apps");
});
it("provides an accessible close button and remembers dismissal", async () => {
const onDismiss = vi.fn();
const renderTip = (opened: boolean) =>
root.render(
<MantineProvider>
<FloatingTip
opened={opened}
showDelay={0}
transitionDuration={0}
closable
closeButtonLabel="Dismiss tip"
rememberDismissal
storageKey="selection"
onDismiss={onDismiss}
>
Select several apps
</FloatingTip>
</MantineProvider>,
);
await act(async () => renderTip(true));
await act(async () => vi.advanceTimersByTime(0));
const closeButton = document.body.querySelector<HTMLButtonElement>('button[aria-label="Dismiss tip"]');
expect(closeButton).not.toBeNull();
await act(async () => closeButton?.click());
expect(onDismiss).toHaveBeenCalledOnce();
expect(window.localStorage.getItem("homarr:floating-tip:selection")).toBe("dismissed");
await act(async () => renderTip(false));
await act(async () => renderTip(true));
await act(async () => vi.advanceTimersByTime(0));
expect(document.body.textContent).not.toContain("Select several apps");
});
});
@@ -0,0 +1,205 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Box, createVarsResolver, factory, Portal, rem, Transition, useProps, useStyles } from "@mantine/core";
import type { AlertProps, BoxProps, ElementProps, Factory, MantineTransition, StylesApiProps } from "@mantine/core";
import classes from "./floating-tip.module.css";
export type FloatingTipStylesNames = "root" | "alert";
export type FloatingTipCssVariables = {
root: "--floating-tip-bottom" | "--floating-tip-max-width" | "--floating-tip-z-index";
};
interface FloatingTipBaseProps
extends BoxProps, StylesApiProps<FloatingTipFactory>, Omit<ElementProps<"div">, "title"> {
/** Controls whether this tip cycle should run. */
opened: boolean;
/** Delay before the tip appears, in milliseconds. */
showDelay?: number;
/** Time the tip remains visible, in milliseconds. Set to `null` to keep it open. */
dismissAfter?: number | null;
/** Enter and exit animation duration, in milliseconds. */
transitionDuration?: number;
/** Mantine transition used to show and hide the tip. */
transition?: MantineTransition;
/** Distance from the bottom edge of the viewport. */
bottomOffset?: number | string;
/** Maximum width of the alert. */
maxWidth?: number | string;
/** Stack order of the floating tip. */
zIndex?: number;
/** Props forwarded to the underlying Mantine Alert. */
alertProps?: Omit<
AlertProps,
"children" | "classNames" | "closeButtonLabel" | "onClose" | "styles" | "withCloseButton"
>;
/** Called when the user closes the tip. */
onDismiss?: () => void;
}
type FloatingTipCloseProps =
| { closable: true; closeButtonLabel: string }
| { closable?: false; closeButtonLabel?: never };
type FloatingTipPersistenceProps =
| { rememberDismissal: true; storageKey: string }
| { rememberDismissal?: false; storageKey?: never };
export type FloatingTipProps = FloatingTipBaseProps & FloatingTipCloseProps & FloatingTipPersistenceProps;
export type FloatingTipFactory = Factory<{
props: FloatingTipProps;
ref: HTMLDivElement;
stylesNames: FloatingTipStylesNames;
vars: FloatingTipCssVariables;
}>;
const defaultProps = {
showDelay: 0,
dismissAfter: null,
transitionDuration: 200,
transition: "slide-up",
bottomOffset: 24,
maxWidth: 520,
zIndex: 1000,
closable: false,
rememberDismissal: false,
} satisfies Partial<FloatingTipProps>;
const toCssSize = (value: number | string | undefined) => (typeof value === "number" ? rem(value) : value);
const varsResolver = createVarsResolver<FloatingTipFactory>((_theme, { bottomOffset, maxWidth, zIndex }) => ({
root: {
"--floating-tip-bottom": toCssSize(bottomOffset),
"--floating-tip-max-width": toCssSize(maxWidth),
"--floating-tip-z-index": zIndex?.toString(),
},
}));
const getDismissalStorageKey = (storageKey: string) => `homarr:floating-tip:${storageKey}`;
export const FloatingTip = factory<FloatingTipFactory>((_props) => {
const props = useProps("FloatingTip", defaultProps, _props);
const {
ref,
classNames,
className,
style,
styles,
unstyled,
vars,
attributes,
opened,
showDelay,
dismissAfter,
transitionDuration,
transition,
bottomOffset: _bottomOffset,
maxWidth: _maxWidth,
zIndex: _zIndex,
closable,
closeButtonLabel,
rememberDismissal,
storageKey,
alertProps,
onDismiss,
children,
...others
} = props;
const [visible, setVisible] = useState(false);
const showTimerRef = useRef<number | null>(null);
const dismissTimerRef = useRef<number | null>(null);
const clearTimers = useCallback(() => {
if (showTimerRef.current !== null) window.clearTimeout(showTimerRef.current);
if (dismissTimerRef.current !== null) window.clearTimeout(dismissTimerRef.current);
showTimerRef.current = null;
dismissTimerRef.current = null;
}, []);
useEffect(() => {
clearTimers();
setVisible(false);
if (!opened) return;
if (rememberDismissal) {
try {
if (window.localStorage.getItem(getDismissalStorageKey(storageKey)) === "dismissed") return;
} catch {
// Storage can be unavailable in private browsing or hardened environments.
}
}
showTimerRef.current = window.setTimeout(() => {
setVisible(true);
showTimerRef.current = null;
if (dismissAfter !== null) {
dismissTimerRef.current = window.setTimeout(() => {
setVisible(false);
dismissTimerRef.current = null;
}, dismissAfter);
}
}, showDelay);
return clearTimers;
}, [clearTimers, dismissAfter, opened, rememberDismissal, showDelay, storageKey]);
const handleDismiss = () => {
clearTimers();
setVisible(false);
if (rememberDismissal) {
try {
window.localStorage.setItem(getDismissalStorageKey(storageKey), "dismissed");
} catch {
// The tip still closes for this render even when persistence is unavailable.
}
}
onDismiss?.();
};
const getStyles = useStyles<FloatingTipFactory>({
name: "FloatingTip",
classes,
props,
className,
style,
classNames,
styles,
unstyled,
vars,
attributes,
varsResolver,
});
return (
<Portal>
<Transition mounted={visible} transition={transition} duration={transitionDuration} timingFunction="ease">
{(transitionStyle) => {
const rootStyles = getStyles("root");
return (
<Box ref={ref} {...others} {...rootStyles} style={[rootStyles.style, transitionStyle]}>
<Alert
{...alertProps}
{...getStyles("alert")}
withCloseButton={closable}
closeButtonLabel={closeButtonLabel}
onClose={handleDismiss}
>
{children}
</Alert>
</Box>
);
}}
</Transition>
</Portal>
);
});
FloatingTip.displayName = "@homarr/ui/FloatingTip";
FloatingTip.classes = classes;
@@ -0,0 +1,7 @@
export { FloatingTip } from "./floating-tip";
export type {
FloatingTipCssVariables,
FloatingTipFactory,
FloatingTipProps,
FloatingTipStylesNames,
} from "./floating-tip";
+8
View File
@@ -13,6 +13,14 @@ export { CustomPasswordInput } from "./password-input/password-input";
export { IntegrationAvatar } from "./integration-avatar";
export { BetaBadge } from "./beta-badge";
export { CatalogItem } from "./catalog-item";
export { SelectableCard } from "./selectable-card";
export type {
SelectableCardProps,
SelectableCardStylesNames,
SelectableCardCssVariables,
SelectableCardFactory,
} from "./selectable-card";
export * from "./floating-tip";
export { MaskedImage } from "./masked-image";
export { MaskedOrNormalImage } from "./masked-or-normal-image";
export { LanguageIcon } from "./language-icon";
@@ -2,7 +2,7 @@ import type { PropsWithChildren } from "react";
import { useState } from "react";
import { Popover, Progress, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { passwordRequirements } from "@homarr/validation/user";
import { PasswordRequirement } from "./password-requirement";
@@ -14,7 +14,7 @@ const strengthColorMap = [
] as const;
export const PasswordRequirementsPopover = ({ password, children }: PropsWithChildren<{ password: string }>) => {
const tPassword = useScopedI18n("user.field.password");
const tPassword = useI18n("user.field.password");
const requirements = useRequirements();
const strength = useStrength(password);
const [popoverOpened, setPopoverOpened] = useState(false);
@@ -46,7 +46,7 @@ export const PasswordRequirementsPopover = ({ password, children }: PropsWithChi
};
const useRequirements = () => {
const t = useScopedI18n("user.field.password.suggestion");
const t = useI18n("user.field.password.suggestion");
return passwordRequirements.map(({ check, value }) => ({ check, label: t(value) }));
};
@@ -0,0 +1,7 @@
export { SelectableCard } from "./selectable-card";
export type {
SelectableCardProps,
SelectableCardStylesNames,
SelectableCardCssVariables,
SelectableCardFactory,
} from "./selectable-card";
@@ -0,0 +1,79 @@
.root {
cursor: pointer;
text-align: start;
display: flex;
flex-direction: column;
position: relative;
width: 100%;
min-height: 175px;
height: 100%;
padding: var(--mantine-spacing-sm);
border-radius: var(--selectable-card-radius, var(--mantine-radius-md));
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
border: 1px solid var(--mantine-color-default-border);
box-shadow: var(--mantine-shadow-xs);
transition:
transform 150ms ease,
box-shadow 150ms ease,
border-color 150ms ease;
overflow: hidden;
font-family: inherit;
color: inherit;
text-decoration: none;
}
.root:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: var(--mantine-shadow-sm);
border-color: var(--mantine-primary-color-filled);
}
.root:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.root[data-selected] {
border-color: var(--mantine-primary-color-filled);
box-shadow: 0 0 0 1px var(--mantine-primary-color-filled);
}
.root:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.header {
border-bottom: 1px solid var(--mantine-color-default-border);
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
padding-top: var(--mantine-spacing-xs);
padding-bottom: var(--mantine-spacing-xs);
margin: calc(var(--mantine-spacing-sm) * -1);
margin-bottom: 0;
padding-left: var(--mantine-spacing-sm);
padding-right: var(--mantine-spacing-sm);
}
.body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: var(--mantine-spacing-xs);
margin-top: var(--mantine-spacing-xs);
margin-bottom: var(--mantine-spacing-xs);
min-height: 0;
}
.footer {
border-top: 1px dashed var(--mantine-color-default-border);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
padding-top: var(--mantine-spacing-xs);
padding-bottom: var(--mantine-spacing-xs);
margin: calc(var(--mantine-spacing-sm) * -1);
margin-top: auto;
padding-left: var(--mantine-spacing-sm);
padding-right: var(--mantine-spacing-sm);
min-height: 40px;
display: flex;
align-items: center;
}
@@ -0,0 +1,7 @@
declare const classes: {
readonly root: string;
readonly header: string;
readonly body: string;
readonly footer: string;
};
export default classes;
@@ -0,0 +1,370 @@
// @vitest-environment jsdom
import { act, createRef } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { Badge, MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SelectableCard } from "./selectable-card";
import classes from "./selectable-card.module.css";
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
describe("SelectableCard", () => {
describe("rendering slots", () => {
it("renders as a native button with default button type and accessible slots", async () => {
const onClick = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
aria-label="Sonarr Card"
icon={<span data-testid="card-icon">Icon</span>}
title="Sonarr"
topRight={<Badge data-testid="card-topright">Media</Badge>}
description="Manage TV series downloads"
footerLeft={<span data-testid="card-footer-left">Left Meta</span>}
footerRight={<span data-testid="card-footer-right">Right Meta</span>}
onClick={onClick}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button).not.toBeNull();
expect(button?.type).toBe("button");
expect(button?.getAttribute("aria-label")).toBe("Sonarr Card");
expect(host.querySelector("[data-testid='card-icon']")).not.toBeNull();
expect(host.textContent).toContain("Sonarr");
expect(host.querySelector("[data-testid='card-topright']")).not.toBeNull();
expect(host.textContent).toContain("Manage TV series downloads");
expect(host.querySelector("[data-testid='card-footer-left']")).not.toBeNull();
expect(host.querySelector("[data-testid='card-footer-right']")).not.toBeNull();
await act(async () => button?.click());
expect(onClick).toHaveBeenCalledOnce();
});
it("renders only header when only header slots (icon, title, topRight) are provided", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
icon={<span data-testid="only-icon">I</span>}
title="Only Header"
topRight={<span data-testid="only-topright">TR</span>}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.querySelector(`.${classes.header}`)).not.toBeNull();
expect(button?.querySelector(`.${classes.body}`)).toBeNull();
expect(button?.querySelector(`.${classes.footer}`)).toBeNull();
expect(host.querySelector("[data-testid='only-icon']")).not.toBeNull();
expect(host.querySelector("[data-testid='only-topright']")).not.toBeNull();
});
it("renders footer when only footerLeft or only footerRight is provided", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Test" footerLeft={<span data-testid="left-only">Left</span>} />
</MantineProvider>,
),
);
expect(host.querySelector(`.${classes.footer}`)).not.toBeNull();
expect(host.querySelector("[data-testid='left-only']")).not.toBeNull();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Test" footerRight={<span data-testid="right-only">Right</span>} />
</MantineProvider>,
),
);
expect(host.querySelector(`.${classes.footer}`)).not.toBeNull();
expect(host.querySelector("[data-testid='right-only']")).not.toBeNull();
});
it("applies Styles API classes for root, header, body, and footer", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
title="Plex"
description="Media Server"
footerLeft={<span>Footer</span>}
classNames={{
root: "custom-root",
header: "custom-header",
body: "custom-body",
footer: "custom-footer",
}}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.classList.contains(classes.root)).toBe(true);
expect(button?.classList.contains("custom-root")).toBe(true);
const header = button?.querySelector(`.${classes.header}`);
expect(header).not.toBeNull();
expect(header?.classList.contains("custom-header")).toBe(true);
const body = button?.querySelector(`.${classes.body}`);
expect(body).not.toBeNull();
expect(body?.classList.contains("custom-body")).toBe(true);
const footer = button?.querySelector(`.${classes.footer}`);
expect(footer).not.toBeNull();
expect(footer?.classList.contains("custom-footer")).toBe(true);
});
it("prioritizes title without wrapping and constrains topRight badge with ellipsis", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
title="Very Long Title Name That Should Not Wrap"
topRight={<Badge>Category Badge</Badge>}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
const header = button?.querySelector(`.${classes.header}`);
expect(header).not.toBeNull();
const titleText = header?.querySelector("p, span, div");
expect(titleText?.textContent).toContain("Very Long Title Name That Should Not Wrap");
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
title={<span data-testid="custom-title-node">Custom Title Element</span>}
topRight={<span data-testid="custom-top-right">Top Right</span>}
/>
</MantineProvider>,
),
);
expect(host.querySelector("[data-testid='custom-title-node']")).not.toBeNull();
expect(host.querySelector("[data-testid='custom-top-right']")).not.toBeNull();
});
it("renders custom children in the body slot over description", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Test" description="This should not appear">
<div data-testid="custom-body-children">Custom Child Content</div>
</SelectableCard>
</MantineProvider>,
),
);
expect(host.querySelector("[data-testid='custom-body-children']")).not.toBeNull();
expect(host.textContent).toContain("Custom Child Content");
expect(host.textContent).not.toContain("This should not appear");
});
it("renders an empty description without fallback copy", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="No Desc Item" description="" />
</MantineProvider>,
),
);
expect(host.textContent).toContain("No Desc Item");
expect(host.textContent).not.toContain("No description provided");
});
it("omits header, body, or footer when corresponding props are omitted", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.querySelector(`.${classes.header}`)).toBeNull();
expect(button?.querySelector(`.${classes.body}`)).toBeNull();
expect(button?.querySelector(`.${classes.footer}`)).toBeNull();
});
});
describe("states", () => {
it("handles selected state via data-selected attribute", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Selected Item" selected />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.getAttribute("data-selected")).toBe("true");
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Unselected Item" selected={false} />
</MantineProvider>,
),
);
expect(button?.hasAttribute("data-selected")).toBe(false);
});
it("handles disabled state and prevents click handlers", async () => {
const onClick = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Disabled Item" disabled onClick={onClick} />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.disabled).toBe(true);
await act(async () => button?.click());
expect(onClick).not.toHaveBeenCalled();
});
it("handles loading state by disabling the button and showing LoadingOverlay", async () => {
const onClick = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Loading Item" loading onClick={onClick} />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.disabled).toBe(true);
await act(async () => button?.click());
expect(onClick).not.toHaveBeenCalled();
});
});
describe("interactions and accessibility", () => {
it("supports focus and click activation", async () => {
const onClick = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Interactive Card" onClick={onClick} />
</MantineProvider>,
),
);
const button = host.querySelector("button");
button?.focus();
expect(document.activeElement).toBe(button);
await act(async () => {
button?.click();
});
expect(onClick).toHaveBeenCalledOnce();
});
it("forwards ref to the underlying HTMLButtonElement", async () => {
const ref = createRef<HTMLButtonElement>();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard ref={ref} title="Ref Card" />
</MantineProvider>,
),
);
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
expect(ref.current?.tagName).toBe("BUTTON");
});
it("resolves radius CSS variables via varsResolver", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Radius Card" radius="lg" />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-lg)");
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Radius Card Sm" radius="sm" />
</MantineProvider>,
),
);
expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-sm)");
});
it("passes arbitrary HTML attributes down to the button element", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
title="Custom Props Card"
data-testid="my-selectable-card"
data-custom="custom-value"
tabIndex={0}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.getAttribute("data-testid")).toBe("my-selectable-card");
expect(button?.getAttribute("data-custom")).toBe("custom-value");
expect(button?.tabIndex).toBe(0);
});
});
});
@@ -0,0 +1,177 @@
"use client";
import type { ReactNode } from "react";
import {
Box,
createVarsResolver,
factory,
Group,
LoadingOverlay,
Text,
Tooltip,
useProps,
useStyles,
} from "@mantine/core";
import type { BoxProps, ElementProps, Factory, MantineRadius, StylesApiProps } from "@mantine/core";
import classes from "./selectable-card.module.css";
export type SelectableCardStylesNames = "root" | "header" | "body" | "footer";
export type SelectableCardCssVariables = {
root: "--selectable-card-radius";
};
export interface SelectableCardProps
extends BoxProps, StylesApiProps<SelectableCardFactory>, Omit<ElementProps<"button">, "title"> {
icon?: ReactNode;
title?: ReactNode;
topRight?: ReactNode;
description?: string | null;
footerLeft?: ReactNode;
footerRight?: ReactNode;
selected?: boolean;
loading?: boolean;
disabled?: boolean;
radius?: MantineRadius;
children?: ReactNode;
}
export type SelectableCardFactory = Factory<{
props: SelectableCardProps;
ref: HTMLButtonElement;
stylesNames: SelectableCardStylesNames;
vars: SelectableCardCssVariables;
}>;
const defaultProps: Partial<SelectableCardProps> = {
type: "button",
};
const varsResolver = createVarsResolver<SelectableCardFactory>((_theme, { radius }) => ({
root: {
"--selectable-card-radius": radius ? `var(--mantine-radius-${radius})` : undefined,
},
}));
export const SelectableCard = factory<SelectableCardFactory>((_props) => {
const props = useProps("SelectableCard", defaultProps, _props);
const {
ref,
classNames,
className,
style,
styles,
unstyled,
vars,
attributes,
icon,
title,
topRight,
description,
footerLeft,
footerRight,
selected,
loading = false,
disabled = false,
children,
type = "button",
...others
} = props;
const getStyles = useStyles<SelectableCardFactory>({
name: "SelectableCard",
classes,
props,
className,
style,
classNames,
styles,
unstyled,
vars,
attributes,
varsResolver,
});
return (
<Box
component="button"
{...others}
type={type}
ref={ref}
disabled={disabled || loading}
data-selected={selected || undefined}
aria-pressed={selected !== undefined ? selected : undefined}
{...getStyles("root")}
>
<LoadingOverlay visible={loading} loaderProps={{ size: "sm" }} />
{/* Header Section: Inset background with Title that never elides before the category */}
{(icon || title || topRight) && (
<Box {...getStyles("header")}>
<Group
justify="space-between"
align="center"
wrap="nowrap"
gap="xs"
style={{ width: "100%", overflow: "hidden" }}
>
<Group gap="xs" wrap="nowrap" style={{ flex: "1 1 auto", minWidth: 0, overflow: "hidden" }}>
{icon}
{typeof title === "string" ? (
<Text
fw={700}
size="md"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{title}
</Text>
) : (
title
)}
</Group>
{topRight && (
<Box
style={{
minWidth: 0,
flexShrink: 0,
}}
>
{topRight}
</Box>
)}
</Group>
</Box>
)}
{/* Body: Custom Children or Default Description */}
{children ? (
<Box {...getStyles("body")}>{children}</Box>
) : description !== undefined ? (
<Box {...getStyles("body")}>
<Tooltip label={description} multiline w={260} disabled={!description}>
<Text size="sm" c="dimmed" lineClamp={2} lh={1.35}>
{description}
</Text>
</Tooltip>
</Box>
) : null}
{/* Footer Section: Inset Dashed Bottom Meta */}
{(footerLeft || footerRight) && (
<Box {...getStyles("footer")}>
<Group justify="space-between" align="center" wrap="nowrap" gap="xs" style={{ width: "100%" }}>
<Box style={{ minWidth: 0, flexShrink: 1 }}>{footerLeft}</Box>
<Box style={{ minWidth: 0, flexShrink: 0 }}>{footerRight}</Box>
</Group>
</Box>
)}
</Box>
);
});
SelectableCard.displayName = "@homarr/ui/SelectableCard";
SelectableCard.classes = classes;
@@ -4,6 +4,7 @@ import { useCallback, useMemo, useState } from "react";
import { Badge, Card, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconBrandDocker, IconCheck, IconPuzzle } from "@tabler/icons-react";
import { getWidgetName } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
@@ -109,7 +110,7 @@ export const IntegrationMultiSelectGrid = ({
<Stack gap={4}>
{integration.widgets.map((widgetKind) => (
<Text key={widgetKind} size="xs">
{t(`widget.${widgetKind}.name`)}
{getWidgetName(widgetKind, t)}
</Text>
))}
</Stack>
@@ -0,0 +1,279 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IntegrationKind } from "@homarr/definitions";
import { IntegrationSelectGrid } from "./integration-select-grid";
vi.mock("@homarr/definitions", async (importOriginal) => {
const actual = await importOriginal<typeof import("@homarr/definitions")>();
return {
...actual,
getWidgetKindsForIntegration: (kind: IntegrationKind) => {
if (kind === "wud") return [];
return actual.getWidgetKindsForIntegration(kind);
},
};
});
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string, values?: { count?: number }) => {
const translations: Record<string, string> = {
"integration.page.list.search": "Search integrations",
"common.noResults": "No results found",
"integration.grid.noWidgets": "No widgets tied to this service",
"integration.grid.tiedWidgets": "Tied Widgets",
"integration.grid.connected": `${values?.count ?? 0} connected`,
"integration.grid.more": `+${values?.count ?? 0} more`,
"widget.mediaServer.name": "Media Server",
"widget.calendar.name": "Calendar",
"widget.downloads.name": "Downloads",
"integration.category.mediaService": "Media Service",
"integration.category.downloadClient": "Download Client",
"integration.category.calendar": "Calendar",
"integration.category.smartHomeServer": "Smart Home",
"integration.category.usenet": "Usenet",
"integration.category.torrent": "Torrent",
};
return translations[key] ?? key;
},
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
describe("IntegrationSelectGrid", () => {
it("renders a 4-column responsive grid of selectable integration cards", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr", "radarr", "sabNzbd"]} />
</MantineProvider>,
),
);
const cards = host.querySelectorAll("button");
expect(cards.length).toBe(4);
expect(host.textContent).toContain("Jellyfin");
expect(host.textContent).toContain("Sonarr");
expect(host.textContent).toContain("Radarr");
expect(host.textContent).toContain("SABnzbd");
});
it("renders tied widgets with icons and labels inside integration cards", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin"]} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Tied Widgets");
// Jellyfin is tied to Media Server widget
expect(host.textContent).toContain("Media Server");
});
it("displays fallback message when integration has no tied widgets", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["wud"]} />
</MantineProvider>,
),
);
expect(host.querySelector("button")).not.toBeNull();
expect(host.textContent).toContain("No widgets tied to this service");
});
it("displays '{count} connected' in footerLeft when integration instances exist", async () => {
const onSelect = vi.fn();
const integrationData = [
{ kind: "jellyfin" as IntegrationKind, name: "Home Jellyfin" },
{ kind: "jellyfin" as IntegrationKind, name: "Remote Jellyfin" },
{ kind: "sonarr" as IntegrationKind, name: "Sonarr Instance" },
];
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid
onSelect={onSelect}
allowedKinds={["jellyfin", "radarr"]}
integrationData={integrationData}
/>
</MantineProvider>,
),
);
// Jellyfin has 2 connected
expect(host.textContent).toContain("2 connected");
// Radarr has 0 connected, so it doesn't show "0 connected"
expect(host.textContent).not.toContain("0 connected");
});
it("filters integrations dynamically by search input", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr", "radarr", "plex"]} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
expect(searchInput).not.toBeNull();
// Type "jelly"
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeInputValueSetter?.call(searchInput, "jelly");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
const cards = host.querySelectorAll("button");
expect(cards.length).toBe(1);
expect(host.textContent).toContain("Jellyfin");
expect(host.textContent).not.toContain("Sonarr");
expect(host.textContent).not.toContain("Radarr");
});
it("displays empty state when no integrations match search query", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr"]} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeInputValueSetter?.call(searchInput, "nonexistent-integration-query");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
expect(host.textContent).toContain("No results found");
expect(host.querySelectorAll("button").length).toBe(0);
});
it("calls onSelect when Enter is pressed with exactly 1 search match", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr"]} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeInputValueSetter?.call(searchInput, "jelly");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
await act(async () => {
searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(onSelect).toHaveBeenCalledWith("jellyfin");
});
it("calls onSelect when an integration card is clicked", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["sonarr"]} />
</MantineProvider>,
),
);
const cardButton = host.querySelector("button");
await act(async () => cardButton?.click());
expect(onSelect).toHaveBeenCalledWith("sonarr");
});
it("includes mock integration when enableMockIntegration is true", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} enableMockIntegration allowedKinds={["mock", "sonarr"]} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Mock");
expect(host.textContent).toContain("Sonarr");
});
it("excludes mock integration when enableMockIntegration is false", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} enableMockIntegration={false} />
</MantineProvider>,
),
);
const buttons = Array.from(host.querySelectorAll("button"));
const mockButton = buttons.find((btn) => btn.getAttribute("aria-label")?.toLowerCase() === "mock");
expect(mockButton).toBeUndefined();
});
});
@@ -1,27 +1,69 @@
"use client";
import { useMemo, useState } from "react";
import { Badge, Card, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconPuzzle } from "@tabler/icons-react";
import { Badge, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import {
IconAd,
IconApps,
IconBinaryTree,
IconBookmark,
IconBrandDocker,
IconBrandMinecraft,
IconBrowser,
IconBuildingBank,
IconBusStop,
IconCalendar,
IconChartBar,
IconClock,
IconClockPlay,
IconCloud,
IconDeviceCctv,
IconDeviceGamepad,
IconDownload,
IconGraphFilled,
IconHeartRateMonitor,
IconHourglass,
IconMessage,
IconMovie,
IconNotes,
IconPuzzle,
IconReportSearch,
IconRobot,
IconRocket,
IconRss,
IconSearch,
IconServer2,
IconTicket,
IconTopologyFull,
IconTransform,
IconVideo,
IconWall,
IconWind,
IconZoomQuestion,
} from "@tabler/icons-react";
import type { IntegrationKind } from "@homarr/definitions";
import { getWidgetName } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "../components/integration-avatar";
import { SelectGridLayout, selectGridCardHeight } from "../select-grid-layout";
import { SelectableCard } from "../components/selectable-card";
import { selectGridCols } from "../select-grid-layout";
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
import classes from "./integration-select-grid.module.css";
import type { IntegrationGridItem } from "./integration-grid-shared";
export interface IntegrationSelectGridProps {
onSelect: (kind: IntegrationKind) => void;
enableMockIntegration?: boolean;
allowedKinds?: readonly IntegrationKind[];
integrationData?: { kind: IntegrationKind; name?: string }[];
}
export const IntegrationSelectGrid = ({
onSelect,
enableMockIntegration = false,
allowedKinds,
integrationData,
}: IntegrationSelectGridProps) => {
const [search, setSearch] = useState("");
const t = useI18n();
@@ -32,75 +74,182 @@ export const IntegrationSelectGrid = ({
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
return (
<SelectGridLayout
search={search}
onSearchChange={setSearch}
placeholder={`${t("integration.page.list.search")}...`}
ariaLabel={t("integration.page.list.search")}
>
{filtered.map((integration) => (
<UnstyledButton
className={classes.choice}
key={integration.kind}
onClick={() => onSelect(integration.kind)}
aria-label={integration.name}
>
<Card h={selectGridCardHeight} className={classes.card} withBorder>
<Stack justify="space-between" h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Text fw={500} lh={1.2} size="sm" lineClamp={2}>
{integration.name}
</Text>
</Group>
<IntegrationGridMeta integration={integration} />
</Stack>
</Card>
</UnstyledButton>
))}
{filtered.length === 0 && (
<Center p="xl" style={{ gridColumn: "1 / -1" }}>
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
);
};
const IntegrationGridMeta = ({ integration }: { integration: ReturnType<typeof buildSortedIntegrations>[number] }) => {
const t = useI18n();
return (
<Stack gap={4} mt="auto">
<Group gap={4} wrap="wrap">
{integration.categories.slice(0, 2).map((category) => (
<Badge key={category} variant="light" size="xs">
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
</Badge>
))}
</Group>
{integration.widgets.length > 0 ? (
<Tooltip
multiline
w={200}
label={
<Stack gap={4}>
{integration.widgets.map((widgetKind) => (
<Text key={widgetKind} size="xs">
{t(`widget.${widgetKind}.name`)}
</Text>
))}
</Stack>
<Stack gap="md">
{/* Top Search Input */}
<Input
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
placeholder={`${t("integration.page.list.search")}...`}
aria-label={t("integration.page.list.search")}
data-autofocus
onKeyDown={(event) => {
if (event.key === "Enter" && filtered.length === 1 && filtered[0]) {
onSelect(filtered[0].kind);
}
>
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
</Badge>
</Tooltip>
) : (
<Badge variant="light" color="gray" size="sm">
{t("integration.grid.noWidgets")}
</Badge>
)}
}}
/>
{/* Scrollable Container with Integration Cards */}
<ScrollArea.Autosize mah="70vh" offsetScrollbars>
<Stack gap="md" pt="xs" pr="xs" px={4}>
<SimpleGrid cols={selectGridCols} spacing="sm">
{filtered.map((integration) => (
<IntegrationCard
key={integration.kind}
integration={integration}
onSelect={onSelect}
connectedCount={(integrationData ?? []).filter((i) => i.kind === integration.kind).length}
/>
))}
</SimpleGrid>
{filtered.length === 0 && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
);
};
// =========================================================================
// IntegrationCard: Shared Modular SelectableCard with Tied Widgets
// =========================================================================
const IntegrationCard = ({
integration,
onSelect,
connectedCount = 0,
}: {
integration: IntegrationGridItem;
onSelect: (kind: IntegrationKind) => void;
connectedCount?: number;
}) => {
const t = useI18n();
return (
<SelectableCard
onClick={() => onSelect(integration.kind)}
aria-label={integration.name}
icon={<IntegrationAvatar kind={integration.kind} size="sm" />}
title={integration.name}
topRight={<IntegrationCategoryBadges categories={integration.categories} limit={1} />}
footerLeft={
connectedCount > 0 ? (
<Badge variant="light" color="teal" size="xs" radius="xs">
{t("integration.grid.connected", { count: connectedCount })}
</Badge>
) : null
}
>
<Text size="10px" tt="uppercase" fw={700} c="dimmed">
{t("integration.grid.tiedWidgets")}
</Text>
<IntegrationTiedWidgets widgets={integration.widgets} />
</SelectableCard>
);
};
const widgetIconsMap: Partial<Record<WidgetKind, typeof IconPuzzle>> = {
clock: IconClock,
weather: IconCloud,
airQuality: IconWind,
countdown: IconHourglass,
timer: IconClockPlay,
app: IconApps,
assistant: IconRobot,
iframe: IconBrowser,
video: IconDeviceCctv,
notebook: IconNotes,
anchorNote: IconNotes,
dnsHoleSummary: IconAd,
dnsHoleControls: IconDeviceGamepad,
"smartHome-entityState": IconBinaryTree,
"smartHome-executeAutomation": IconBinaryTree,
stockPrice: IconBuildingBank,
mediaServer: IconVideo,
calendar: IconCalendar,
downloads: IconDownload,
"mediaRequests-requestList": IconZoomQuestion,
"mediaRequests-requestStats": IconChartBar,
mediaTranscoding: IconTransform,
mediaMissing: IconMovie,
minecraftServerStatus: IconBrandMinecraft,
networkControllerSummary: IconTopologyFull,
networkControllerStatus: IconTopologyFull,
rssFeed: IconRss,
bookmarks: IconBookmark,
indexerManager: IconReportSearch,
healthMonitoring: IconHeartRateMonitor,
releases: IconRocket,
mediaReleases: IconTicket,
dockerContainers: IconBrandDocker,
firewall: IconWall,
notifications: IconMessage,
systemResources: IconGraphFilled,
coolify: IconCloud,
systemDisks: IconServer2,
timetable: IconBusStop,
};
const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; limit?: number }) => {
const t = useI18n();
if (widgets.length === 0) {
return (
<Text size="xs" c="dimmed" fs="italic">
{t("integration.grid.noWidgets")}
</Text>
);
}
const items = limit ? widgets.slice(0, limit) : widgets;
const moreCount = limit ? widgets.length - limit : 0;
return (
<Group gap={4} wrap="wrap">
{items.map((widgetKind) => {
const WidgetIcon = widgetIconsMap[widgetKind] ?? IconPuzzle;
return (
<Badge
key={widgetKind}
variant="default"
radius="xs"
size="xs"
leftSection={<WidgetIcon size={12} />}
style={{ fontWeight: 500 }}
>
{getWidgetName(widgetKind, t)}
</Badge>
);
})}
{moreCount > 0 && (
<Badge variant="subtle" color="gray" size="xs" radius="xs">
{t("integration.grid.more", { count: moreCount })}
</Badge>
)}
</Group>
);
};
const IntegrationCategoryBadges = ({
categories,
limit,
}: {
categories: IntegrationGridItem["categories"];
limit?: number;
}) => {
const t = useI18n();
const items = limit ? categories.slice(0, limit) : categories;
return (
<Group gap={4} wrap="wrap">
{items.map((category) => (
<Badge key={category} variant="light" size="xs" color="gray" radius="xs">
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
</Badge>
))}
</Group>
);
};
+4 -1
View File
@@ -13,7 +13,9 @@
},
"exports": {
".": "./index.ts",
"./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./inputs": "./src/_inputs/index.ts",
"./inputs/form": "./src/_inputs/form.ts",
"./inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./custom-api/component": "./src/custom-api/component.tsx",
"./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx",
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
@@ -23,6 +25,7 @@
"./errors": "./src/errors/component.tsx",
"./manifest": "./src/manifest.ts",
"./options": "./src/options.ts",
"./option-translation": "./src/option-translation.ts",
"./errors/classes": "./src/errors/index.ts",
"./widget-integration-select": "./src/widget-integration-select.tsx",
"./modals": "./src/modals/index.ts",
+17 -3
View File
@@ -1,7 +1,11 @@
import type { WidgetKind } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetOptionOfType, WidgetOptionType } from "../options";
import {
getWidgetOptionDescriptionTranslationNamespace,
getWidgetOptionTranslationNamespace,
} from "../option-translation";
export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
kind: WidgetKind;
@@ -25,7 +29,17 @@ type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "plac
* is defined for the option. The method does sadly not reconize issues with those definitions. So it does not yell at you when you somewhere show the label without having it defined in the translations.
*/
export const useWidgetInputTranslation = (kind: WidgetKind, property: string): UseWidgetInputTranslationReturnType => {
return useScopedI18n(
`widget.${kind}.option.${property}` as never, // Because the type is complex and not recognized by typescript, we need to cast it to never to make it work.
const inputNamespace = getWidgetOptionTranslationNamespace(kind, property);
const descriptionNamespace = getWidgetOptionDescriptionTranslationNamespace(kind, property);
const tInput = useI18n(
inputNamespace as never, // Because the type is complex and not recognized by typescript, we need to cast it to never to make it work.
) as unknown as UseWidgetInputTranslationReturnType;
const tDescription = useI18n(
(descriptionNamespace ?? inputNamespace) as never,
) as unknown as UseWidgetInputTranslationReturnType;
return (key) => {
if (key === "description" && descriptionNamespace) return tDescription("description");
return tInput(key);
};
};
@@ -5,16 +5,16 @@ import { Loader, Select } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetAnchorNoteInput = ({ property, kind, options }: CommonWidgetInputProps<"anchorNote">) => {
const t = useI18n();
const tCommon = useI18n("common");
const tWidget = useWidgetInputTranslation(kind, property);
const tAnchor = useScopedI18n("widget.anchorNote");
const tAnchor = useI18n("widget.anchorNote");
const form = useFormContext();
const integrationId = form.values.integrationIds[0];
@@ -46,7 +46,7 @@ export const WidgetAnchorNoteInput = ({ property, kind, options }: CommonWidgetI
<Select
label={tWidget("label")}
description={options.withDescription ? tWidget("description") : undefined}
placeholder={integrationId ? t("common.select.placeholder") : tAnchor("integrationRequired")}
placeholder={integrationId ? tCommon("select.placeholder") : tAnchor("integrationRequired")}
nothingFoundMessage={integrationId ? tAnchor("noNotes") : tAnchor("integrationRequired")}
searchable
searchValue={search}
@@ -17,8 +17,15 @@ import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
const renderHereLink = (children: React.ReactNode) => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{children}
</Anchor>
);
export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">) => {
const t = useI18n();
const tCommon = useI18n("common");
const tApp = useI18n("widget.common.app");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const { data: apps, isPending, refetch } = clientApi.app.selectable.useQuery();
@@ -38,7 +45,7 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
label={tInput("label")}
searchable
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
nothingFoundMessage={t("widget.common.app.noData")}
nothingFoundMessage={tApp("noData")}
renderOption={renderSelectOption}
data={
apps?.map((app) => ({
@@ -49,13 +56,9 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
}
inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text size="xs">
{t.rich("widget.common.app.description", {
here: () => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{t("common.here")}
</Anchor>
),
<Text component="span" size="xs">
{tApp.rich("description", {
here: () => renderHereLink(tCommon("here")),
})}
</Text>
}
@@ -77,7 +80,7 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
})
}
>
{t("widget.common.app.quickCreate")}
{tApp("quickCreate")}
</Button>
)}
</SimpleGrid>
@@ -25,7 +25,7 @@ import { normalizeCustomWidgetOptions, validateCustomWidgetOptions } from "@homa
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import { IconPicker } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useFormContext } from "./form";
@@ -34,7 +34,7 @@ export const WidgetCustomWidgetConfigurationInput = ({
property,
}: CommonWidgetInputProps<"customWidgetConfiguration">) => {
const form = useFormContext();
const labels = useScopedI18n("widget.customApi.configuration");
const labels = useI18n("widget.customApi.configuration");
const board = useOptionalBoard();
const definitionId = typeof form.values.options.definitionId === "string" ? form.values.options.definitionId : "";
const configurationValue = form.values.options[property];
@@ -72,7 +72,7 @@ vi.mock("@homarr/boards/context", () => ({
}));
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) =>
useI18n: () => (key: string) =>
({
migrationRequired: "Migration required",
migrationDescription: "Copy its redacted prompt to an LLM, then paste the single JSON response into Homarr.",
@@ -19,7 +19,7 @@ import { IconAlertTriangle, IconApi } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { CommonWidgetInputProps } from "./common";
@@ -32,7 +32,7 @@ export const WidgetCustomWidgetSelectInput = ({
options,
}: CommonWidgetInputProps<"customWidgetSelect">) => {
const t = useWidgetInputTranslation(kind, property);
const labels = useScopedI18n("widget.customApi.picker");
const labels = useI18n("widget.customApi.picker");
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const form = useFormContext();
@@ -24,7 +24,7 @@ import dayjs from "dayjs";
import { createId } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { formatWallDateTime, resolveWallDateTimeString } from "../countdown/recurrence";
import type { DateTimeEventRecurrence, OptionDateTimeEvent } from "../options";
@@ -314,4 +314,4 @@ type EventListTranslationKey =
type EventListTranslation = (key: EventListTranslationKey, values?: Record<string, string | number>) => string;
const useEventListTranslation = (kind: WidgetKind) =>
useScopedI18n(`widget.${kind}.dateTimeEventList` as never) as unknown as EventListTranslation;
useI18n(`widget.${kind}.dateTimeEventList` as never) as unknown as EventListTranslation;
@@ -2,7 +2,7 @@
import { Loader, MultiSelect } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
@@ -13,7 +13,8 @@ export const WidgetDynamicMultiSelectInput = ({
kind,
options,
}: CommonWidgetInputProps<"dynamicMultiSelect">) => {
const t = useScopedI18n("widget.dynamicSelect");
const t = useI18n("widget.common.select");
const tCommon = useI18n("common");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const inputProps = form.getInputProps(`options.${property}`);
@@ -36,7 +37,7 @@ export const WidgetDynamicMultiSelectInput = ({
searchable
clearable
maxValues={options.maxValues}
nothingFoundMessage={t("noResults")}
nothingFoundMessage={tCommon("noResults")}
data={data}
disabled={isError}
error={isError ? t("loadError") : inputProps.error}
@@ -6,7 +6,7 @@ import { useDebouncedValue } from "@mantine/hooks";
import { IconCheck } from "@tabler/icons-react";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
@@ -25,7 +25,7 @@ export const WidgetDynamicSelectInput = ({
boardId,
}: CommonWidgetInputProps<"dynamicSelect">) => {
const t = useI18n();
const tSelect = useScopedI18n("widget.dynamicSelect");
const tCommon = useI18n("common");
const tWidget = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const inputProps = form.getInputProps(`options.${property}`);
@@ -78,7 +78,7 @@ export const WidgetDynamicSelectInput = ({
searchValue={search}
onSearchChange={setSearch}
placeholder={tWidget("placeholder")}
nothingFoundMessage={tSelect("noResults")}
nothingFoundMessage={tCommon("noResults")}
leftSection={isPending && <Loader size="xs" />}
renderOption={({ option, checked }) => {
return (
@@ -2,20 +2,20 @@
import { MultiSelect, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
const useIntegrationSelectTranslation = () => useScopedI18n("widget.integrationSelect");
export const WidgetIntegrationMultiSelectInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"integrationMultiSelect">) => {
const t = useIntegrationSelectTranslation();
const t = useI18n("widget.integrationSelect");
const tSelect = useI18n("widget.common.select");
const tCommon = useI18n("common");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -40,8 +40,8 @@ export const WidgetIntegrationMultiSelectInput = ({
}
const description = options.withDescription ? tInput("description") : undefined;
const placeholder = isPending ? t("loading") : tInput("placeholder");
const loadError = isError ? t("loadError") : undefined;
const placeholder = isPending ? tSelect("loading") : tInput("placeholder");
const loadError = isError ? tSelect("loadError") : undefined;
return (
<MultiSelect
@@ -51,7 +51,7 @@ export const WidgetIntegrationMultiSelectInput = ({
placeholder={placeholder}
searchable
clearable
nothingFoundMessage={t("noResults")}
nothingFoundMessage={tCommon("noResults")}
data={data}
disabled={isError}
error={loadError ?? inputProps.error}
@@ -2,20 +2,20 @@
import { Select, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
const useIntegrationSelectTranslation = () => useScopedI18n("widget.integrationSelect");
export const WidgetIntegrationSelectInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"integrationSelect">) => {
const t = useIntegrationSelectTranslation();
const t = useI18n("widget.integrationSelect");
const tSelect = useI18n("widget.common.select");
const tCommon = useI18n("common");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -35,8 +35,8 @@ export const WidgetIntegrationSelectInput = ({
}
const description = options.withDescription ? tInput("description") : undefined;
const placeholder = isPending ? t("loading") : t("placeholder");
const error = isError ? t("loadError") : undefined;
const placeholder = isPending ? tSelect("loading") : t("placeholder");
const error = isError ? tSelect("loadError") : undefined;
return (
<Select
@@ -45,7 +45,7 @@ export const WidgetIntegrationSelectInput = ({
placeholder={placeholder}
clearable={options.clearable}
searchable={options.searchable}
nothingFoundMessage={t("noResults")}
nothingFoundMessage={tCommon("noResults")}
data={data}
disabled={isError}
error={error}
@@ -46,7 +46,7 @@ vi.mock("@homarr/modals", () => ({
vi.mock("@homarr/translation/client", () => ({
useCurrentIntlLocale: () => "en",
useScopedI18n: () => (key: string) => key,
useI18n: () => (key: string) => key,
}));
vi.mock("./common", async (importOriginal) => ({
@@ -21,7 +21,7 @@ import { IconClick, IconListSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, useModalAction } from "@homarr/modals";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { formatLocalizedCompactNumber } from "../common/locale";
import type { OptionLocation } from "../options";
@@ -31,7 +31,7 @@ import { useFormContext } from "./form";
export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInputProps<"location">) => {
const t = useWidgetInputTranslation(kind, property);
const tLocation = useScopedI18n("widget.common.location");
const tLocation = useI18n("widget.common.location");
const form = useFormContext();
const { openModal } = useModalAction(LocationSearchModal);
const fieldPath = `options.${property}`;
@@ -149,8 +149,8 @@ interface LocationSearchInnerProps {
}
const LocationSearchModal = createModal<LocationSearchInnerProps>(({ actions, innerProps }) => {
const t = useScopedI18n("widget.common.location.table");
const tCommon = useScopedI18n("common");
const t = useI18n("widget.common.location.table");
const tCommon = useI18n("common");
const { data, isPending, error } = clientApi.location.searchCity.useQuery({
query: innerProps.query,
});
@@ -216,7 +216,7 @@ interface LocationSearchTableRowProps {
}
const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => {
const t = useScopedI18n("widget.common.location.table");
const t = useI18n("widget.common.location.table");
const locale = useCurrentIntlLocale();
const onSelect = useCallback(() => {
onLocationSelect({
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { Combobox, Pill, PillsInput, useCombobox } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
@@ -9,7 +9,7 @@ import { useFormContext } from "./form";
export const WidgetMultiTextInput = ({ property, kind, options }: CommonWidgetInputProps<"multiText">) => {
const t = useWidgetInputTranslation(kind, property);
const tCommon = useScopedI18n("common");
const tCommon = useI18n("common");
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
@@ -48,7 +48,7 @@ import {
import { IconPicker } from "@homarr/forms-collection";
import { createModal, useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { MaskedImage } from "@homarr/ui";
import type { ReleasesRepository, ReleasesVersionFilter } from "../releases/releases-repository";
@@ -69,7 +69,8 @@ export const WidgetMultiReleasesRepositoriesInput = ({
itemId,
}: CommonWidgetInputProps<"multiReleasesRepositories">) => {
const t = useWidgetInputTranslation(kind, property);
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const actionT = useI18n("common.action");
const form = useFormContext();
const repositories = form.values.options[property] as ReleasesRepository[];
const { openModal: openEditModal } = useModalAction(RepositoryEditModal);
@@ -232,7 +233,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
leftSection={<IconEdit size="var(--mantine-font-size-sm)" />}
size="xs"
>
{tRepository("edit.label")}
{actionT("edit")}
</Button>
<ActionIcon variant="transparent" color="red" onClick={() => onRepositoryRemove(index)}>
@@ -267,7 +268,7 @@ const providersWithAuth: ReleaseProviderKind[] = [
];
const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repositories: ReleasesRepository[] }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId });
const setSecret = clientApi.widget.secrets.setSecret.useMutation({
onSuccess: () => refetch(),
@@ -331,7 +332,8 @@ const ProviderTokenInput = ({
onSave: (value: string) => Promise<void>;
onDelete: () => Promise<void>;
}) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const actionT = useI18n("common.action");
const [value, setValue] = useState("");
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
@@ -375,7 +377,7 @@ const ProviderTokenInput = ({
/>
{editing && value.trim() && (
<Button size="xs" onClick={handleSave} loading={saving}>
{tRepository("tokens.save")}
{actionT("save")}
</Button>
)}
{hasToken && !editing && (
@@ -412,7 +414,8 @@ interface RepositoryEditProps {
}
const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, actions }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const actionT = useI18n("common.action");
const [loading, setLoading] = useState(false);
const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository }));
const [formErrors, setFormErrors] = useState<FormErrors>({});
@@ -576,11 +579,11 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
<Divider my={"sm"} />
<Group justify="flex-end">
<Button variant="default" onClick={handleCancel} color="gray.5">
{tRepository("editForm.cancel.label")}
{actionT("cancel")}
</Button>
<Button data-autofocus onClick={handleConfirm} loading={loading}>
{tRepository("editForm.confirm.label")}
{actionT("confirm")}
</Button>
</Group>
</Stack>
@@ -611,7 +614,7 @@ const ImportRepositorySelect = ({
disabled = false,
onImageSelectionChanged = undefined,
}: ImportRepositorySelectProps) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const provider = repository.provider;
return (
@@ -687,7 +690,8 @@ interface RepositoryImportProps {
}
const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps, actions }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const actionT = useI18n("common.action");
const [loading, setLoading] = useState(false);
const [selectedImages, setSelectedImages] = useState([] as ReleasesRepositoryImport[]);
@@ -847,11 +851,11 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
<Group justify="flex-end">
<Button variant="default" onClick={actions.closeModal} color="gray.5">
{tRepository("editForm.cancel.label")}
{actionT("cancel")}
</Button>
<Button onClick={handleConfirm} loading={loading} disabled={selectedImages.length === 0}>
{tRepository("editForm.confirm.label")}
{actionT("confirm")}
</Button>
</Group>
</Stack>
@@ -22,7 +22,7 @@ import { IconGripVertical, IconPlus, IconTrash } from "@tabler/icons-react";
import { createId } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { OptionTimezone } from "../options";
import type { CommonWidgetInputProps } from "./common";
@@ -246,4 +246,4 @@ type TimezoneListTranslationKey =
type TimezoneListTranslation = (key: TimezoneListTranslationKey, values?: Record<string, string | number>) => string;
const useTimezoneListTranslation = (kind: WidgetKind) =>
useScopedI18n(`widget.${kind}.timezoneList` as never) as unknown as TimezoneListTranslation;
useI18n(`widget.${kind}.timezoneList` as never) as unknown as TimezoneListTranslation;
@@ -3,14 +3,15 @@
import { Select, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetUmamiEventInput = ({ property, kind }: CommonWidgetInputProps<"umamiEventName">) => {
const t = useScopedI18n("widget.umami.option.eventName");
const t = useI18n("widget.umami.option.eventName");
const tEvents = useI18n("widget.umami.option.events");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -25,7 +26,7 @@ export const WidgetUmamiEventInput = ({ property, kind }: CommonWidgetInputProps
if (!websiteId || integrationIds.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("configureWebsiteFirst")}
{tEvents("configureWebsiteFirst")}
</Text>
);
}
@@ -36,9 +37,9 @@ export const WidgetUmamiEventInput = ({ property, kind }: CommonWidgetInputProps
<Select
label={tInput("label")}
description={tInput("description")}
placeholder={isPending ? t("loading") : undefined}
placeholder={isPending ? tEvents("loading") : undefined}
searchable
nothingFoundMessage={t("noEvents")}
nothingFoundMessage={tEvents("noEvents")}
data={data}
{...form.getInputProps(`options.${property}`)}
/>
@@ -3,14 +3,15 @@
import { MultiSelect, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetUmamiEventNamesInput = ({ property, kind }: CommonWidgetInputProps<"umamiEventNames">) => {
const t = useScopedI18n("widget.umami.option.eventNames");
const t = useI18n("widget.umami.option.eventNames");
const tEvents = useI18n("widget.umami.option.events");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -25,7 +26,7 @@ export const WidgetUmamiEventNamesInput = ({ property, kind }: CommonWidgetInput
if (!websiteId || integrationIds.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("configureWebsiteFirst")}
{tEvents("configureWebsiteFirst")}
</Text>
);
}
@@ -34,9 +35,9 @@ export const WidgetUmamiEventNamesInput = ({ property, kind }: CommonWidgetInput
<MultiSelect
label={tInput("label")}
description={tInput("description")}
placeholder={isPending ? t("loading") : t("placeholder")}
placeholder={isPending ? tEvents("loading") : t("placeholder")}
searchable
nothingFoundMessage={t("noEvents")}
nothingFoundMessage={tEvents("noEvents")}
data={eventNames ?? []}
{...form.getInputProps(`options.${property}`)}
/>
@@ -3,14 +3,14 @@
import { Select, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetUmamiWebsiteInput = ({ property, kind }: CommonWidgetInputProps<"umamiWebsite">) => {
const t = useScopedI18n("widget.umami.option.websiteId");
const t = useI18n("widget.umami.option.websiteId");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -6,7 +6,7 @@ import { Box, Group, Paper, ScrollArea, SimpleGrid, Stack, Text, VisuallyHidden
import { IconMapPin } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
@@ -28,7 +28,7 @@ import {
} from "./display";
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
const t = useScopedI18n("widget.airQuality");
const t = useI18n("widget.airQuality");
const query = clientApi.widget.airQuality.atLocation.useQuery({
latitude: options.location.latitude,
longitude: options.location.longitude,
@@ -66,7 +66,7 @@ const CompactAirQuality = ({
width,
}: AirQualityViewProps & { height: number; width: number }) => {
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.airQuality");
const t = useI18n("widget.airQuality");
const layout = getCompactAirQualityLayout(width, height);
const standard = getAqiStandard(options.aqiStandard, locale);
const value = getAqiValue(airQuality.current, standard);
@@ -158,7 +158,7 @@ const CompactAirQuality = ({
const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps & { width: number }) => {
const chartId = useId();
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.airQuality");
const t = useI18n("widget.airQuality");
const standard = getAqiStandard(options.aqiStandard, locale);
const value = getAqiValue(airQuality.current, standard);
const category = getAqiCategory(value, standard);
@@ -358,7 +358,7 @@ const pollutantKeys: PollutantKey[] = ["pm2_5", "pm10", "ozone", "nitrogenDioxid
const pollenKeys: PollenKey[] = ["alder", "birch", "grass", "mugwort", "olive", "ragweed"];
const useAirQualityLabels = () => {
const t = useScopedI18n("widget.airQuality");
const t = useI18n("widget.airQuality");
return {
pollutants: {
pm2_5: t("pollutant.pm2_5"),
@@ -389,7 +389,7 @@ const formatDecimal = (value: number | null, locale: string) => {
return new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value);
};
type ScopedAirQualityTranslator = ReturnType<typeof useScopedI18n<"widget.airQuality">>;
type ScopedAirQualityTranslator = ReturnType<typeof useI18n<"widget.airQuality">>;
const formatConcentration = (value: number | null, locale: string, t: ScopedAirQualityTranslator) =>
t("unit.concentration", { value: formatDecimal(value, locale) });
+11 -9
View File
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useTimeAgo } from "@homarr/common";
import type { AnchorNotePermission } from "@homarr/integrations";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
@@ -42,7 +42,7 @@ export default function AnchorNoteWidget({
height,
displayMode,
}: WidgetComponentProps<"anchorNote">) {
const t = useScopedI18n("widget.anchorNote");
const t = useI18n("widget.anchorNote");
const noteId = options.noteId.trim();
if (!noteId) {
return (
@@ -85,7 +85,9 @@ const AnchorNoteWidgetContent = ({
height,
isAdvanced,
}: AnchorNoteWidgetContentProps) => {
const t = useScopedI18n("widget.anchorNote");
const t = useI18n("widget.anchorNote");
const tWidgetCommon = useI18n("widget.common");
const actionT = useI18n("common.action");
const locale = useCurrentIntlLocale();
const noteQuery = clientApi.widget.anchorNotes.getNote.useQuery({
integrationId,
@@ -206,7 +208,7 @@ const AnchorNoteWidgetContent = ({
)}
{!isEditing && (isAdvanced || options.showUpdatedAt) && (
<Text size="xs" c="dimmed">
{t("updatedAt", { date: updatedAtRelative })}
{tWidgetCommon("updatedAt", { date: updatedAtRelative })}
</Text>
)}
{!isEditing && isViewer && (
@@ -243,21 +245,21 @@ const AnchorNoteWidgetContent = ({
{isEditing ? (
<>
<Button size="xs" onClick={handleSave} loading={isUpdating} disabled={!hasChanges || !canEdit}>
{t("save")}
{actionT("save")}
</Button>
<Button size="xs" variant="subtle" onClick={handleCancel} disabled={isUpdating}>
{t("cancel")}
{actionT("cancel")}
</Button>
</>
) : isAdvanced ? (
<Button size="xs" variant="light" onClick={handleEdit} disabled={isUpdating}>
{t("edit")}
{actionT("edit")}
</Button>
) : (
<Tooltip label={t("edit")}>
<Tooltip label={actionT("edit")}>
<ActionIcon
className={actionTargetClasses.root}
aria-label={t("edit")}
aria-label={actionT("edit")}
size="md"
variant="light"
onClick={handleEdit}
+6 -4
View File
@@ -23,10 +23,11 @@ import { PingDot } from "./ping/ping-dot";
import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
const t = useI18n();
const tCommon = useI18n("common");
const tApp = useI18n("widget.app");
const settings = useSettings();
const board = useRequiredBoard();
const appQuery = clientApi.app.byId.useQuery({ id: options.appId });
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
const app = getUsableWidgetQueryData(appQuery);
const href = getSafeAppHref(app?.href);
useRegisterSpotlightContextResults(
@@ -50,6 +51,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
[app, href, options.openInNewTab],
);
if (!options.appId) return <WidgetEmptyState />;
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
if (!app) return <WidgetEmptyState />;
@@ -120,14 +122,14 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
!settings.forceDisableStatus &&
!board.disableStatus &&
Boolean(app.pingUrl ?? app.href) ? (
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${tCommon("action.loading")}…`} />}>
<PingIndicator appId={app.id} />
</Suspense>
) : null}
</AppLink>
{appQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={appQuery.error} label={t("widget.app.name")} />
<WidgetQueryErrorIndicator error={appQuery.error} label={tApp("name")} />
</Box>
)}
</Box>
+2 -2
View File
@@ -55,7 +55,7 @@ export const { definition, componentLoader } = createWidgetDefinition("app", {
options: [
{
label(t) {
return t("widget.app.option.layout.option.column");
return t("widget.common.layout.option.vertical");
},
value: "column",
icon: IconLayoutNavbarExpand,
@@ -69,7 +69,7 @@ export const { definition, componentLoader } = createWidgetDefinition("app", {
},
{
label(t) {
return t("widget.app.option.layout.option.row");
return t("widget.common.layout.option.horizontal");
},
value: "row",
icon: IconLayoutSidebarLeftExpand,
@@ -10,12 +10,12 @@ interface PingIndicatorProps {
}
export const PingIndicator = ({ appId }: PingIndicatorProps) => {
const t = useI18n();
const t = useI18n("common");
const { data: pingResult, error } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
if (error) return <PingDot icon={IconX} color="red" tooltip={t("common.error")} />;
if (error) return <PingDot icon={IconX} color="red" tooltip={t("error")} />;
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />;
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("action.loading")}…`} />;
const isError = "error" in pingResult || pingResult.statusCode >= 500;
@@ -5,7 +5,7 @@ import { Avatar, Badge, Card, Group, ScrollArea, SimpleGrid, Stack, Text } from
import { clientApi } from "@homarr/api/client";
import { formatByteRate } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
@@ -47,7 +47,8 @@ const ArchiveTeamWarriorWidgetContent = ({
height: number;
isAdvanced: boolean;
}) => {
const t = useScopedI18n("widget.archiveTeamWarrior");
const t = useI18n("widget.archiveTeamWarrior");
const tWidgetCommon = useI18n("widget.common");
const locale = useCurrentIntlLocale();
const [data, statusQuery] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
@@ -107,7 +108,7 @@ const ArchiveTeamWarriorWidgetContent = ({
{t("runnerStatus", { status: t(`status.${getStatusKey(status.runnerStatus ?? status.status)}`) })}
</Text>
<Text size="xs" c="dimmed">
{t("updatedAt", { date: new Date(status.updatedAt).toLocaleString(locale) })}
{tWidgetCommon("updatedAt", { date: new Date(status.updatedAt).toLocaleString(locale) })}
</Text>
</Group>
<Group gap="xs" wrap="wrap">
+2 -2
View File
@@ -3,14 +3,14 @@
import { Center, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconRobot } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { useAssistantWidgetRenderer } from "./context";
export default function AssistantWidget(props: WidgetComponentProps<"assistant">) {
const Renderer = useAssistantWidgetRenderer();
const t = useScopedI18n("widget.assistant.unavailable");
const t = useI18n("widget.assistant.unavailable");
if (Renderer) return <Renderer {...props} />;
@@ -4,7 +4,7 @@ import type { CSSProperties } from "react";
import { Text } from "@mantine/core";
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared";
@@ -49,7 +49,7 @@ export function AudioStatsContent({
width,
height,
}: AudioStatsContentProps) {
const t = useScopedI18n(AUDIO_STATS_TRANSLATION_SCOPE);
const t = useI18n(AUDIO_STATS_TRANSLATION_SCOPE);
const compact = options.compactMode ?? false;
const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats, showAllStats), compact);
const visibleStats = enabledStats.slice(0, getVisibleStatLimit(width, height, enabledStats.length, compact));
@@ -23,7 +23,8 @@ export default function AudioStatsWidget({
displayMode = "compact",
widgetRuntimeRef,
}: WidgetComponentProps<"audioStats">) {
const t = useI18n();
const tAudio = useI18n("widget.audioStats");
const tMediaServer = useI18n("widget.mediaServer");
const statsInput = { integrationId: integrationIds[0] ?? "" };
const { data: response, error: statsError } = clientApi.widget.audioStats.getStats.useQuery(statsInput);
const streamsInput = { integrationIds: [statsInput.integrationId], showOnlyPlaying: true };
@@ -56,7 +57,7 @@ export default function AudioStatsWidget({
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={statsError} label={t("widget.audioStats.name")} />
<WidgetQueryErrorIndicator error={statsError} label={tAudio("name")} />
</Box>
{summary}
</Box>
@@ -74,8 +75,8 @@ export default function AudioStatsWidget({
<div style={{ minHeight: 150 }}>{summary}</div>
{(statsError || streamsError || hasStreamErrors) && (
<Group justify="flex-end">
<WidgetQueryErrorIndicator error={statsError} label={t("widget.audioStats.name")} />
<WidgetQueryErrorIndicator error={streamsError} label={t("widget.mediaServer.name")} />
<WidgetQueryErrorIndicator error={statsError} label={tAudio("name")} />
<WidgetQueryErrorIndicator error={streamsError} label={tMediaServer("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
)}
Loaded 100 of 583 files, more files were not shown because too many files have changed in this diff. Show more