Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab6a0f3b96 | ||
|
|
d6b6287d6d |
No files matched your search
-14
@@ -20,17 +20,6 @@ ARG DISABLE_REDIS_LOGS='true'
|
|||||||
|
|
||||||
RUN corepack enable pnpm && pnpm build
|
RUN corepack enable pnpm && pnpm build
|
||||||
|
|
||||||
# The standalone output tracing omits server chunk source maps (.next/server/**/*.map), which is
|
|
||||||
# where production Node stack frames point. Copy the .map files into the standalone tree (preserving
|
|
||||||
# directory structure) so the existing standalone COPY ships them, keeping traces readable.
|
|
||||||
# See https://github.com/homarr-labs/homarr/issues/5891
|
|
||||||
RUN cd apps/nextjs/.next/server && \
|
|
||||||
find . -name '*.map' | while IFS= read -r map; do \
|
|
||||||
dest="../standalone/apps/nextjs/.next/server/$map"; \
|
|
||||||
mkdir -p "$(dirname "$dest")"; \
|
|
||||||
cp "$map" "$dest"; \
|
|
||||||
done
|
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -75,9 +64,6 @@ ENV DB_DRIVER='better-sqlite3'
|
|||||||
ENV AUTH_PROVIDERS='credentials'
|
ENV AUTH_PROVIDERS='credentials'
|
||||||
ENV REDIS_IS_EXTERNAL='false'
|
ENV REDIS_IS_EXTERNAL='false'
|
||||||
ENV NODE_ENV='production'
|
ENV NODE_ENV='production'
|
||||||
# Tell Node to symbolicate stack traces using the shipped .map files so production logs
|
|
||||||
# show original source locations instead of minified frames, see https://github.com/homarr-labs/homarr/issues/5891
|
|
||||||
ENV NODE_OPTIONS='--enable-source-maps'
|
|
||||||
|
|
||||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||||
CMD ["sh", "run.sh"]
|
CMD ["sh", "run.sh"]
|
||||||
@@ -31,8 +31,6 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
// Ship browser source maps so production client stack traces are readable, see https://github.com/homarr-labs/homarr/issues/5891
|
|
||||||
productionBrowserSourceMaps: true,
|
|
||||||
// react compiler breaks mantine-react-table, so disabled for now
|
// react compiler breaks mantine-react-table, so disabled for now
|
||||||
//reactCompiler: true,
|
//reactCompiler: true,
|
||||||
/** We already do typechecking as separate tasks in CI */
|
/** We already do typechecking as separate tasks in CI */
|
||||||
@@ -43,8 +41,6 @@ const nextConfig: NextConfig = {
|
|||||||
*/
|
*/
|
||||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||||
experimental: {
|
experimental: {
|
||||||
// Ship server source maps so production Node stack traces in logs reference original source, see https://github.com/homarr-labs/homarr/issues/5891
|
|
||||||
serverSourceMaps: true,
|
|
||||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||||
turbopackFileSystemCacheForDev: true,
|
turbopackFileSystemCacheForDev: true,
|
||||||
preloadEntriesOnStart: false,
|
preloadEntriesOnStart: false,
|
||||||
|
|||||||
@@ -149,20 +149,16 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
interface DockerTableProps {
|
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
|
||||||
initialData?: RouterOutputs["docker"]["getContainers"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DockerTable({ initialData }: DockerTableProps) {
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tDocker = useScopedI18n("docker");
|
const tDocker = useScopedI18n("docker");
|
||||||
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
|
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||||
initialData,
|
initialData: { containers, timestamp },
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
});
|
});
|
||||||
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
|
const relativeTime = useTimeAgo(data.timestamp);
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
@@ -180,11 +176,8 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const containers = data?.containers ?? [];
|
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
data: containers,
|
data: data.containers,
|
||||||
state: { isLoading: isFetching },
|
|
||||||
enableDensityToggle: false,
|
enableDensityToggle: false,
|
||||||
enableColumnActions: false,
|
enableColumnActions: false,
|
||||||
enableColumnFilters: false,
|
enableColumnFilters: false,
|
||||||
@@ -197,7 +190,7 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
|||||||
enableBottomToolbar: false,
|
enableBottomToolbar: false,
|
||||||
positionGlobalFilter: "right",
|
positionGlobalFilter: "right",
|
||||||
mantineSearchTextInputProps: {
|
mantineSearchTextInputProps: {
|
||||||
placeholder: tDocker("table.search", { count: String(containers.length) }),
|
placeholder: tDocker("table.search", { count: String(data.containers.length) }),
|
||||||
style: { minWidth: 300 },
|
style: { minWidth: 300 },
|
||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default async function DockerPage() {
|
|||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{tDocker("title")}</Title>
|
<Title order={1}>{tDocker("title")}</Title>
|
||||||
<DockerTable initialData={{ containers, timestamp }} />
|
<DockerTable containers={containers} timestamp={timestamp} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { createModal } from "@homarr/modals";
|
|
||||||
|
|
||||||
import { DockerTable } from "~/app/[locale]/manage/tools/docker/docker-table";
|
|
||||||
|
|
||||||
export const DockerQuickAccessModal = createModal<void>(() => <DockerTable />).withOptions({
|
|
||||||
defaultTitle: (t) => t("docker.title"),
|
|
||||||
size: 1400,
|
|
||||||
});
|
|
||||||
@@ -3,7 +3,6 @@ import { UnstyledButton } from "@mantine/core";
|
|||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { env } from "@homarr/docker/env";
|
|
||||||
|
|
||||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||||
@@ -12,10 +11,9 @@ import { UpdateIndicator } from "./update";
|
|||||||
export const UserButton = async () => {
|
export const UserButton = async () => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const isAdmin = session?.user.permissions.includes("admin");
|
const isAdmin = session?.user.permissions.includes("admin");
|
||||||
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
|
||||||
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
|
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
|
||||||
return (
|
return (
|
||||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
|
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise}>
|
||||||
<UnstyledButton>
|
<UnstyledButton>
|
||||||
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
||||||
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
||||||
|
|||||||
@@ -5,15 +5,7 @@ import { useCallback, useEffect } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||||
import { useHotkeys, useTimeout } from "@mantine/hooks";
|
import { useHotkeys, useTimeout } from "@mantine/hooks";
|
||||||
import {
|
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
|
||||||
IconBrandDocker,
|
|
||||||
IconCheck,
|
|
||||||
IconHome,
|
|
||||||
IconLogin,
|
|
||||||
IconLogout,
|
|
||||||
IconSettings,
|
|
||||||
IconTool,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { signOut, useSession } from "@homarr/auth/client";
|
import { signOut, useSession } from "@homarr/auth/client";
|
||||||
@@ -25,16 +17,14 @@ import { Link } from "@homarr/ui";
|
|||||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||||
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
|
||||||
import { AvailableUpdatesMenuItem } from "./layout/header/update";
|
import { AvailableUpdatesMenuItem } from "./layout/header/update";
|
||||||
|
|
||||||
interface UserAvatarMenuProps {
|
interface UserAvatarMenuProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
||||||
isDockerEnabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
|
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
|
||||||
const t = useScopedI18n("common.userAvatar.menu");
|
const t = useScopedI18n("common.userAvatar.menu");
|
||||||
const { toggleColorScheme } = useMantineColorScheme();
|
const { toggleColorScheme } = useMantineColorScheme();
|
||||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||||
@@ -44,7 +34,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
|
|||||||
|
|
||||||
const { logoutUrl } = useAuthContext();
|
const { logoutUrl } = useAuthContext();
|
||||||
const { openModal } = useModalAction(LogoutModal);
|
const { openModal } = useModalAction(LogoutModal);
|
||||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
|
||||||
|
|
||||||
const handleSignout = useCallback(async () => {
|
const handleSignout = useCallback(async () => {
|
||||||
await signOut({
|
await signOut({
|
||||||
@@ -91,11 +80,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
|
|||||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||||
{t("management")}
|
{t("management")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{isDockerEnabled && (
|
|
||||||
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
|
||||||
{t("docker")}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|||||||
@@ -15,36 +15,20 @@ export const iconsRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.input(iconsFindSchema)
|
.input(iconsFindSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const term = input.searchText?.toLowerCase().trim() ?? "";
|
|
||||||
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
|
|
||||||
|
|
||||||
let whereCondition = undefined;
|
|
||||||
if (term.length > 0) {
|
|
||||||
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
icons: await ctx.db.query.iconRepositories.findMany({
|
icons: await ctx.db.query.iconRepositories.findMany({
|
||||||
with: {
|
with: {
|
||||||
icons: {
|
icons: {
|
||||||
columns: { id: true, name: true, url: true },
|
columns: {
|
||||||
where: whereCondition,
|
id: true,
|
||||||
orderBy: (table, { asc, sql }) => {
|
name: true,
|
||||||
// ponytail: SVG first, then alphabetical; with a search term,
|
url: true,
|
||||||
// prepend a relevance tier (exact > prefix > substring).
|
|
||||||
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
|
|
||||||
const nameAsc = asc(table.name);
|
|
||||||
if (term.length === 0) {
|
|
||||||
return [svgFirst, nameAsc];
|
|
||||||
}
|
|
||||||
const tier = sql`CASE
|
|
||||||
WHEN LOWER(${table.name}) = ${term} THEN 0
|
|
||||||
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
|
|
||||||
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
|
|
||||||
ELSE 3
|
|
||||||
END`;
|
|
||||||
return [tier, svgFirst, nameAsc];
|
|
||||||
},
|
},
|
||||||
|
where:
|
||||||
|
(input.searchText?.length ?? 0) > 0
|
||||||
|
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
|
||||||
|
: undefined,
|
||||||
limit: input.limitPerGroup,
|
limit: input.limitPerGroup,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { FocusEventHandler } from "react";
|
import type { FocusEventHandler } from "react";
|
||||||
import { startTransition, useEffect, useState } from "react";
|
import { startTransition } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
Combobox,
|
Combobox,
|
||||||
Flex,
|
Flex,
|
||||||
@@ -21,7 +18,7 @@ import {
|
|||||||
useCombobox,
|
useCombobox,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
||||||
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
|
import { IconUpload } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
@@ -37,6 +34,7 @@ interface IconPickerProps {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
onFocus?: FocusEventHandler;
|
onFocus?: FocusEventHandler;
|
||||||
onBlur?: FocusEventHandler;
|
onBlur?: FocusEventHandler;
|
||||||
|
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
withAsterisk?: boolean;
|
withAsterisk?: boolean;
|
||||||
@@ -52,30 +50,29 @@ export const IconPicker = ({
|
|||||||
label,
|
label,
|
||||||
placeholder,
|
placeholder,
|
||||||
}: IconPickerProps) => {
|
}: IconPickerProps) => {
|
||||||
const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
|
const [value, setValue] = useUncontrolled({
|
||||||
const [query, setQuery] = useState("");
|
value: propsValue,
|
||||||
|
onChange,
|
||||||
// ponytail: reset the typed query when the committed value changes
|
});
|
||||||
// externally (form reset, parent re-render) so the input reverts to the URL.
|
const [search, setSearch] = useUncontrolled({
|
||||||
useEffect(() => {
|
value,
|
||||||
setQuery("");
|
onChange: (value) => {
|
||||||
}, [value]);
|
setValue(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [previewUrl, setPreviewUrl] = useUncontrolled({
|
||||||
|
value: propsValue ?? null,
|
||||||
|
});
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
const [debouncedQuery] = useDebouncedValue(query, 100);
|
|
||||||
|
|
||||||
// ponytail: only search when the typed query differs from the committed
|
const [debouncedSearch] = useDebouncedValue(search, 100);
|
||||||
// value. On mount the input shows the existing URL (edit mode), which
|
|
||||||
// would never match the bare filenames in the icon table.
|
|
||||||
const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || "";
|
|
||||||
|
|
||||||
// ponytail: not useSuspenseQuery — an above Suspense boundary would
|
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
|
||||||
// trigger on every keystroke and the dropdown flash is bad UX.
|
const { data } = clientApi.icon.findIcons.useQuery({
|
||||||
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
|
searchText: debouncedSearch,
|
||||||
{ searchText },
|
});
|
||||||
{ placeholderData: (prev) => prev },
|
|
||||||
);
|
|
||||||
|
|
||||||
const combobox = useCombobox({
|
const combobox = useCombobox({
|
||||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||||
@@ -93,15 +90,25 @@ export const IconPicker = ({
|
|||||||
>
|
>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const value = item.url;
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setValue(item.url);
|
setPreviewUrl(value);
|
||||||
setQuery("");
|
setSearch(value);
|
||||||
|
setValue(value);
|
||||||
combobox.closeDropdown();
|
combobox.closeDropdown();
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
||||||
<Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
|
<Card
|
||||||
|
p="sm"
|
||||||
|
pos="relative"
|
||||||
|
style={{
|
||||||
|
overflow: "visible",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
className={classes.iconCard}
|
||||||
|
>
|
||||||
<Box w={25} h={25}>
|
<Box w={25} h={25}>
|
||||||
<Image src={item.url} w={25} h={25} />
|
<Image src={item.url} w={25} h={25} />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -123,48 +130,6 @@ export const IconPicker = ({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ponytail: show the typed query if non-empty, else fall back to the URL.
|
|
||||||
const inputValue = query || value || "";
|
|
||||||
// ponytail: show the existing icon as a left-section preview.
|
|
||||||
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
|
|
||||||
// ponytail: prop override -> real count -> loading text.
|
|
||||||
const placeholderText =
|
|
||||||
placeholder ||
|
|
||||||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
|
|
||||||
tCommon("iconPicker.headerLoading");
|
|
||||||
|
|
||||||
const renderDropdown = () => {
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<Stack align="center" gap="xs" p="md">
|
|
||||||
<IconSearchOff size={32} stroke={1.5} />
|
|
||||||
<Text size="sm">{tCommon("iconPicker.error")}</Text>
|
|
||||||
<Button size="xs" variant="default" onClick={() => refetch()}>
|
|
||||||
{tCommon("action.tryAgain")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isLoading) {
|
|
||||||
return Array(15)
|
|
||||||
.fill(0)
|
|
||||||
.map((_, index) => (
|
|
||||||
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
|
||||||
<Skeleton height={25} visible />
|
|
||||||
</Combobox.Option>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if (totalOptions > 0) {
|
|
||||||
return <Stack gap={4}>{groups}</Stack>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Stack align="center" gap="xs" p="md">
|
|
||||||
<IconMoodEmpty size={32} stroke={1.5} />
|
|
||||||
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox store={combobox} withinPortal>
|
<Combobox store={combobox} withinPortal>
|
||||||
<Combobox.Target>
|
<Combobox.Target>
|
||||||
@@ -172,11 +137,16 @@ export const IconPicker = ({
|
|||||||
<InputBase
|
<InputBase
|
||||||
flex={1}
|
flex={1}
|
||||||
rightSection={<Combobox.Chevron />}
|
rightSection={<Combobox.Chevron />}
|
||||||
leftSection={leftSection}
|
leftSection={
|
||||||
value={inputValue}
|
shouldShowPreview(previewUrl) ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null
|
||||||
|
}
|
||||||
|
value={search}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
combobox.openDropdown();
|
combobox.openDropdown();
|
||||||
setQuery(event.currentTarget.value);
|
combobox.updateSelectedOptionIndex();
|
||||||
|
setSearch(event.currentTarget.value);
|
||||||
|
setValue(event.currentTarget.value);
|
||||||
|
setPreviewUrl(null);
|
||||||
}}
|
}}
|
||||||
onClick={() => combobox.openDropdown()}
|
onClick={() => combobox.openDropdown()}
|
||||||
onFocus={(event) => {
|
onFocus={(event) => {
|
||||||
@@ -186,21 +156,25 @@ export const IconPicker = ({
|
|||||||
onBlur={(event) => {
|
onBlur={(event) => {
|
||||||
onBlur?.(event);
|
onBlur?.(event);
|
||||||
combobox.closeDropdown();
|
combobox.closeDropdown();
|
||||||
setQuery("");
|
setPreviewUrl(value);
|
||||||
|
setSearch(value || "");
|
||||||
}}
|
}}
|
||||||
rightSectionPointerEvents="none"
|
rightSectionPointerEvents="none"
|
||||||
withAsterisk={withAsterisk}
|
withAsterisk={withAsterisk}
|
||||||
error={error}
|
error={error}
|
||||||
label={label ?? tCommon("iconPicker.label")}
|
label={label ?? tCommon("iconPicker.label")}
|
||||||
placeholder={placeholderText}
|
placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}
|
||||||
/>
|
/>
|
||||||
{session?.user.permissions.includes("media-upload") && (
|
{session?.user.permissions.includes("media-upload") && (
|
||||||
<UploadMedia
|
<UploadMedia
|
||||||
onSuccess={(medias) => {
|
onSuccess={(medias) => {
|
||||||
const first = medias.at(0);
|
const first = medias.at(0);
|
||||||
if (!first) return;
|
if (!first) return;
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setValue(first.url);
|
setValue(first.url);
|
||||||
|
setPreviewUrl(first.url);
|
||||||
|
setSearch(first.url);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -216,7 +190,17 @@ export const IconPicker = ({
|
|||||||
|
|
||||||
<Combobox.Dropdown>
|
<Combobox.Dropdown>
|
||||||
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
||||||
{renderDropdown()}
|
{totalOptions > 0 ? (
|
||||||
|
<Stack gap={4}>{groups}</Stack>
|
||||||
|
) : (
|
||||||
|
Array(15)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, index: number) => (
|
||||||
|
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
||||||
|
<Skeleton height={25} visible />
|
||||||
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</Combobox.Options>
|
</Combobox.Options>
|
||||||
</Combobox.Dropdown>
|
</Combobox.Dropdown>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
@@ -225,8 +209,8 @@ export const IconPicker = ({
|
|||||||
|
|
||||||
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
|
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
|
||||||
// See https://github.com/homarr-labs/homarr/issues/3070
|
// See https://github.com/homarr-labs/homarr/issues/3070
|
||||||
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
|
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i" /* ignore casing */);
|
||||||
const shouldShowPreview = (value: string | null | undefined): value is string => {
|
const shouldShowPreview = (value: string | null): value is string => {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
return !localizationPathRegex.test(value);
|
return !localizationPathRegex.test(value);
|
||||||
};
|
};
|
||||||
@@ -3374,27 +3374,27 @@
|
|||||||
"pausedCount": "Pausiert"
|
"pausedCount": "Pausiert"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"internalServerError": "Fehler beim Abrufen der Laufzeit Daten"
|
"internalServerError": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"audioStats": {
|
"audioStats": {
|
||||||
"name": "Audiostatistiken",
|
"name": "Audiostatistiken",
|
||||||
"description": "Bibliotheks- und Wiedergabestatistiken von Navidrome oder Audiobookshelf",
|
"description": "",
|
||||||
"option": {
|
"option": {
|
||||||
"showArtists": {
|
"showArtists": {
|
||||||
"label": "Künstler anzeigen",
|
"label": "Künstler anzeigen",
|
||||||
"description": "Zeige die Gesamtzahl der Künstler in deiner Musikbibliothek (Navidrome)"
|
"description": ""
|
||||||
},
|
},
|
||||||
"showAlbums": {
|
"showAlbums": {
|
||||||
"label": "Alben anzeigen",
|
"label": "",
|
||||||
"description": "Zeigt die Gesamtzahl der Alben in deiner Musikbibliothek an (Navidrome)"
|
"description": "Zeigt die Gesamtzahl der Alben in deiner Musikbibliothek an (Navidrome)"
|
||||||
},
|
},
|
||||||
"showSongs": {
|
"showSongs": {
|
||||||
"label": "Songs anzeigen",
|
"label": "Songs anzeigen",
|
||||||
"description": "Zeigt die Gesamtzahl der Titel in deiner Musikbibliothek an (Navidrome)"
|
"description": ""
|
||||||
},
|
},
|
||||||
"showNowPlaying": {
|
"showNowPlaying": {
|
||||||
"label": "\"Sie hören\" anzeigen",
|
"label": "",
|
||||||
"description": ""
|
"description": ""
|
||||||
},
|
},
|
||||||
"showNowPlayingList": {
|
"showNowPlayingList": {
|
||||||
@@ -3720,7 +3720,7 @@
|
|||||||
},
|
},
|
||||||
"chart": {
|
"chart": {
|
||||||
"cpu": {
|
"cpu": {
|
||||||
"title": "CPU Auslastung",
|
"title": "CPU-Auslastung",
|
||||||
"subtitle": "",
|
"subtitle": "",
|
||||||
"series": "CPU"
|
"series": "CPU"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -177,7 +177,7 @@
|
|||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"label": "Password",
|
"label": "Password",
|
||||||
"suggestionsTitle": "Password suggestions",
|
"suggestionsTitle": "",
|
||||||
"suggestion": {
|
"suggestion": {
|
||||||
"length": "Includes a minimum of 8 characters",
|
"length": "Includes a minimum of 8 characters",
|
||||||
"lowercase": "Includes a lower-case letter",
|
"lowercase": "Includes a lower-case letter",
|
||||||
@@ -687,28 +687,28 @@
|
|||||||
"customWidget": {
|
"customWidget": {
|
||||||
"page": {
|
"page": {
|
||||||
"list": {
|
"list": {
|
||||||
"title": "Custom Widgets",
|
"title": "",
|
||||||
"noResults": "No custom widgets yet. Create your first one!",
|
"noResults": "",
|
||||||
"loading": "Loading..."
|
"loading": ""
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
"title": "Create Custom Widget",
|
"title": "",
|
||||||
"subtitle": "Define an API URL, authentication, and how to display the response data."
|
"subtitle": ""
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Edit Custom Widget"
|
"title": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"create": "Create",
|
"create": "",
|
||||||
"import": "Import",
|
"import": "",
|
||||||
"save": "Save",
|
"save": "",
|
||||||
"delete": "Delete Custom Widget",
|
"delete": "",
|
||||||
"deleteConfirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
|
"deleteConfirm": "",
|
||||||
"export": "Export",
|
"export": "",
|
||||||
"addMapping": "Add Mapping",
|
"addMapping": "",
|
||||||
"addColumn": "Add Column",
|
"addColumn": "",
|
||||||
"edit": "Edit",
|
"edit": "",
|
||||||
"removeMapping": "",
|
"removeMapping": "",
|
||||||
"removeColumn": "",
|
"removeColumn": "",
|
||||||
"addItem": "",
|
"addItem": "",
|
||||||
@@ -5599,71 +5599,71 @@
|
|||||||
"label": "",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Select your colour scheme"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"locale": {
|
"locale": {
|
||||||
"label": "Language",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Select your preferred language"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultSearchEngineId": {
|
"defaultSearchEngineId": {
|
||||||
"label": "Default search engine",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Select your default search engine"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"openSearchInNewTab": {
|
"openSearchInNewTab": {
|
||||||
"label": "Open search results in new tab",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Open search results in new tab"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ddgBangs": {
|
"ddgBangs": {
|
||||||
"label": "Enable DuckDuckGo bangs",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Enable DuckDuckGo bangs"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"firstDayOfWeek": {
|
"firstDayOfWeek": {
|
||||||
"label": "First day of the week",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Select the first day of the week"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"homeBoardId": {
|
"homeBoardId": {
|
||||||
"label": "Home board",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Select your home board"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mobileHomeBoardId": {
|
"mobileHomeBoardId": {
|
||||||
"label": "Mobile home board",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Select your mobile home board"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pingIconsEnabled": {
|
"pingIconsEnabled": {
|
||||||
"label": "Use icons for pings",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Use icons for pings"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1412,10 +1412,7 @@
|
|||||||
"here": "here",
|
"here": "here",
|
||||||
"iconPicker": {
|
"iconPicker": {
|
||||||
"label": "Icon URL",
|
"label": "Icon URL",
|
||||||
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
|
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you."
|
||||||
"headerLoading": "Type to search icons…",
|
|
||||||
"noResults": "No icons match \"{search}\".",
|
|
||||||
"error": "Couldn't load icons."
|
|
||||||
},
|
},
|
||||||
"colorScheme": {
|
"colorScheme": {
|
||||||
"options": {
|
"options": {
|
||||||
@@ -1467,7 +1464,6 @@
|
|||||||
"switchToDarkMode": "Switch to dark mode",
|
"switchToDarkMode": "Switch to dark mode",
|
||||||
"switchToLightMode": "Switch to light mode",
|
"switchToLightMode": "Switch to light mode",
|
||||||
"management": "Management",
|
"management": "Management",
|
||||||
"docker": "Docker",
|
|
||||||
"preferences": "Your preferences",
|
"preferences": "Your preferences",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
@@ -4296,7 +4292,7 @@
|
|||||||
"notStarted": "Not started"
|
"notStarted": "Not started"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"management": "Management",
|
"management": "Management",
|
||||||
"dashboard": "Dashboard"
|
"dashboard": "Dashboard"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4943,8 +4943,8 @@
|
|||||||
"search": "Cerca {count} container",
|
"search": "Cerca {count} container",
|
||||||
"selected": "{selectCount} di {totalCount} containers selezionati",
|
"selected": "{selectCount} di {totalCount} containers selezionati",
|
||||||
"footer": "Totale container {count}",
|
"footer": "Totale container {count}",
|
||||||
"totalCpu": "CPU Totale: {cpu}%",
|
"totalCpu": "",
|
||||||
"totalMemory": "Mem Totale: {memory}"
|
"totalMemory": ""
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -687,7 +687,7 @@
|
|||||||
"customWidget": {
|
"customWidget": {
|
||||||
"page": {
|
"page": {
|
||||||
"list": {
|
"list": {
|
||||||
"title": "Власні віджети",
|
"title": "",
|
||||||
"noResults": "",
|
"noResults": "",
|
||||||
"loading": ""
|
"loading": ""
|
||||||
},
|
},
|
||||||
@@ -1021,7 +1021,7 @@
|
|||||||
"mediaMonitoring": "",
|
"mediaMonitoring": "",
|
||||||
"speedtest": "",
|
"speedtest": "",
|
||||||
"analytics": "",
|
"analytics": "",
|
||||||
"mediaLibrary": "Медіа бібліотека",
|
"mediaLibrary": "",
|
||||||
"uptimeMonitoring": ""
|
"uptimeMonitoring": ""
|
||||||
},
|
},
|
||||||
"testConnection": {
|
"testConnection": {
|
||||||
@@ -1139,7 +1139,7 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"connection": {
|
"connection": {
|
||||||
"hostUnreachable": "",
|
"hostUnreachable": "",
|
||||||
"networkUnreachable": "Мережа недоступна. Перевірте підключення до Інтернету або налаштування мережі.",
|
"networkUnreachable": "",
|
||||||
"refused": "",
|
"refused": "",
|
||||||
"reset": ""
|
"reset": ""
|
||||||
},
|
},
|
||||||
@@ -1328,7 +1328,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"paperlessNgx": {
|
"paperlessNgx": {
|
||||||
"name": "Безпаперовий ngx",
|
"name": "",
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1679,9 +1679,9 @@
|
|||||||
"label": "",
|
"label": "",
|
||||||
"option": {
|
"option": {
|
||||||
"row": "",
|
"row": "",
|
||||||
"row-reverse": "Горизонтально (зворотнє)",
|
"row-reverse": "",
|
||||||
"column": "",
|
"column": "",
|
||||||
"column-reverse": "Вертикальна (перевернута)"
|
"column-reverse": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"descriptionDisplayMode": {
|
"descriptionDisplayMode": {
|
||||||
@@ -1690,7 +1690,7 @@
|
|||||||
"option": {
|
"option": {
|
||||||
"normal": "",
|
"normal": "",
|
||||||
"tooltip": "",
|
"tooltip": "",
|
||||||
"hidden": "Приховати"
|
"hidden": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1703,7 +1703,7 @@
|
|||||||
},
|
},
|
||||||
"bookmarks": {
|
"bookmarks": {
|
||||||
"name": "Закладки",
|
"name": "Закладки",
|
||||||
"description": "Показує декілька посилань додатка",
|
"description": "",
|
||||||
"option": {
|
"option": {
|
||||||
"title": {
|
"title": {
|
||||||
"label": "Назва"
|
"label": "Назва"
|
||||||
@@ -1747,8 +1747,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"archiveTeamWarrior": {
|
"archiveTeamWarrior": {
|
||||||
"name": "АрхівTeam Warrior",
|
"name": "",
|
||||||
"description": "Слідкуйте за станом, проєктами та активними архівними завданнями ArchiveTeam Warrior.",
|
"description": "",
|
||||||
"option": {
|
"option": {
|
||||||
"showBroadcastMessage": {
|
"showBroadcastMessage": {
|
||||||
"label": ""
|
"label": ""
|
||||||
@@ -1778,7 +1778,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"internalServerError": "Не вдалося отримати резюме DNS Hole",
|
"internalServerError": "",
|
||||||
"integrationsDisconnected": "Немає даних, всі інтеграції відключені"
|
"integrationsDisconnected": "Немає даних, всі інтеграції відключені"
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
@@ -1808,7 +1808,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"showToggleAllButtons": {
|
"showToggleAllButtons": {
|
||||||
"label": "Показати всі кнопки"
|
"label": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
|||||||
Reference in new issue
Block a user