feat: refine configurable header and branding
This commit is contained in:
1 parent
803d590a16
commit
f8d3d5893a
47 files changed
+234
-8634
No files matched your search
@@ -21,7 +21,7 @@ import { env } from "~/env";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
import type { Board, Item } from "../_types";
|
||||
import { ClientBoard } from "./_client";
|
||||
import { BoardContentHeaderActions } from "./_header-actions";
|
||||
import { BoardContentEditAction, BoardContentSettingsAction } from "./_header-actions";
|
||||
|
||||
const logger = createLogger({ module: "createBoardContentPage" });
|
||||
const getQueryClient = cache(makeQueryClient);
|
||||
@@ -37,7 +37,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
}: Props<TParams>) => {
|
||||
return {
|
||||
layout: createBoardLayout({
|
||||
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||
headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour: true,
|
||||
}),
|
||||
|
||||
@@ -39,10 +39,9 @@ const loadBoardEditorAsync = async () =>
|
||||
]);
|
||||
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
|
||||
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n("board");
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess) return null;
|
||||
@@ -50,20 +49,27 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
return (
|
||||
<>
|
||||
{isEditMode && <BoardEditActions />}
|
||||
|
||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||
|
||||
{!demoReadOnly && (
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</TourTarget>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const BoardContentSettingsAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n("board");
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess || demoReadOnly) return null;
|
||||
|
||||
return (
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
|
||||
|
||||
@@ -28,13 +28,17 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
||||
const logger = createLogger({ module: "createBoardLayout" });
|
||||
|
||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||
headerActions: JSX.Element;
|
||||
headerActions?: JSX.Element;
|
||||
headerBoardEditAction?: JSX.Element;
|
||||
headerBoardSettingsAction?: JSX.Element;
|
||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||
withTour?: boolean;
|
||||
}
|
||||
|
||||
export const createBoardLayout = <TParams extends Params>({
|
||||
headerActions,
|
||||
headerBoardEditAction,
|
||||
headerBoardSettingsAction,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour = false,
|
||||
}: CreateBoardLayoutProps<TParams>) => {
|
||||
@@ -108,6 +112,8 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
<MainHeader
|
||||
logo={<BoardLogo size={appShellLogoHeight} />}
|
||||
actions={headerActions}
|
||||
boardEditAction={headerBoardEditAction}
|
||||
boardSettingsAction={headerBoardSettingsAction}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { headers } from "next/headers";
|
||||
|
||||
import { getOnboardingClaimTokenFromCookieHeader, isOnboardingClaimValidAsync } from "@homarr/api/onboarding-claim";
|
||||
import { normalizeOnboardingStep } from "@homarr/api/onboarding-step";
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
@@ -20,12 +19,11 @@ import { getPackageVersion } from "~/versions/package-reader";
|
||||
import { AssistantConfiguration } from "../manage/assistant/_components/assistant-configuration";
|
||||
|
||||
export default async function InitPage() {
|
||||
const [state, session, requestHeaders, firstUser, serverSettings] = await Promise.all([
|
||||
const [state, session, requestHeaders, firstUser] = await Promise.all([
|
||||
db.query.onboarding.findFirst(),
|
||||
auth(),
|
||||
headers(),
|
||||
db.query.users.findFirst({ columns: { id: true } }),
|
||||
getRscServerSettingsAsync(),
|
||||
]);
|
||||
const canConfigurePrivileged = session?.user.permissions.includes("admin") ?? false;
|
||||
const onboardingClaim = getOnboardingClaimTokenFromCookieHeader(requestHeaders.get("cookie"));
|
||||
@@ -74,11 +72,7 @@ export default async function InitPage() {
|
||||
availableBoards: availableBoards.map(({ id, name }) => ({ id, name })),
|
||||
}}
|
||||
sqliteRestore={databaseDriver === "sqlite" ? <DatabaseRestoreFlow variant="standalone" /> : undefined}
|
||||
assistantConfiguration={
|
||||
canConfigurePrivileged && serverSettings.featureControls.assistantEnabled ? (
|
||||
<AssistantConfiguration />
|
||||
) : undefined
|
||||
}
|
||||
assistantConfiguration={canConfigurePrivileged ? <AssistantConfiguration /> : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -113,11 +113,7 @@ export default async function Layout(props: {
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const assistantAvailabilityPromise: Promise<AssistantAvailability> = Promise.all([
|
||||
sessionPromise,
|
||||
serverSettingsPromise,
|
||||
]).then(([session, serverSettings]) => {
|
||||
if (!serverSettings.featureControls.assistantEnabled) return "disabled";
|
||||
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) => {
|
||||
if (!session) return "unauthenticated";
|
||||
return api.assistant
|
||||
.getAvailability()
|
||||
@@ -163,7 +159,6 @@ export default async function Layout(props: {
|
||||
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
|
||||
user: { enableGravatar: serverSettings.user.enableGravatar },
|
||||
branding: serverSettings.branding,
|
||||
featureControls: serverSettings.featureControls,
|
||||
}}
|
||||
{...innerProps}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
@@ -17,9 +16,9 @@ export async function generateMetadata() {
|
||||
}
|
||||
|
||||
export default async function AssistantPage() {
|
||||
const [session, serverSettings] = await Promise.all([auth(), getRscServerSettingsAsync()]);
|
||||
const session = await auth();
|
||||
|
||||
if (!serverSettings.featureControls.assistantEnabled || !session?.user.permissions.includes("admin")) {
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
@@ -62,12 +61,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [t, tEntities, session, shouldRunManageTour, serverSettings] = await Promise.all([
|
||||
const [t, tEntities, session, shouldRunManageTour] = await Promise.all([
|
||||
getI18n("management.navbar"),
|
||||
getI18n("common.entity"),
|
||||
sessionPromise,
|
||||
shouldRunManageTourPromise,
|
||||
getRscServerSettingsAsync(),
|
||||
]);
|
||||
const appsAccess = getAppsSectionAccess(session);
|
||||
const integrationsAccess = await getIntegrationsSectionAccessAsync(session);
|
||||
@@ -203,7 +201,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
label: t("items.assistant"),
|
||||
href: "/manage/assistant",
|
||||
icon: IconRobot,
|
||||
hidden: !serverSettings.featureControls.assistantEnabled || !session?.user.permissions.includes("admin"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: t("items.settings"),
|
||||
|
||||
@@ -46,6 +46,7 @@ export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
|
||||
<TextInput label={t("appName.label")} {...form.getInputProps("branding.appName")} />
|
||||
<IconPicker
|
||||
label={t("logo.label")}
|
||||
description={null}
|
||||
placeholder={t("logo.placeholder")}
|
||||
withAsterisk={false}
|
||||
value={logoInput.value ?? ""}
|
||||
@@ -54,6 +55,7 @@ export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
|
||||
/>
|
||||
<IconPicker
|
||||
label={t("favicon.label")}
|
||||
description={null}
|
||||
placeholder={t("favicon.placeholder")}
|
||||
withAsterisk={false}
|
||||
value={faviconInput.value ?? ""}
|
||||
@@ -108,6 +110,7 @@ export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
|
||||
<TextInput label={t("greeting.label")} {...form.getInputProps("branding.greeting")} />
|
||||
<IconPicker
|
||||
label={t("signInBackground.label")}
|
||||
description={null}
|
||||
placeholder={t("signInBackground.placeholder")}
|
||||
withAsterisk={false}
|
||||
value={backgroundInput.value ?? ""}
|
||||
@@ -142,7 +145,7 @@ export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("authVisibility.description")}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="xs" verticalSpacing="xs">
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
<Switch
|
||||
size="sm"
|
||||
label={t("authVisibility.appName")}
|
||||
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SimpleGrid, Switch } from "@mantine/core";
|
||||
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
|
||||
interface FeatureControlsSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const FeatureControlsSettingsForm = ({ form }: FeatureControlsSettingsFormProps) => {
|
||||
const t = useI18n("management.page.settings.section.featureControls");
|
||||
|
||||
return (
|
||||
<SectionCard title={t("title")}>
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="md" verticalSpacing="sm">
|
||||
<Switch
|
||||
label={t("assistant.label")}
|
||||
description={t("assistant.description")}
|
||||
{...form.getInputProps("featureControls.assistantEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("boardSwitcher.label")}
|
||||
description={t("boardSwitcher.description")}
|
||||
{...form.getInputProps("featureControls.boardSwitcherEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("widgetContextMenu.label")}
|
||||
description={t("widgetContextMenu.description")}
|
||||
{...form.getInputProps("featureControls.widgetContextMenuEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,7 @@ import { colorSchemes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { brandingServerSettingsSchema, featureControlsServerSettingsSchema } from "@homarr/server-settings";
|
||||
import { brandingServerSettingsSchema } from "@homarr/server-settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
||||
@@ -22,7 +22,6 @@ import { BoardSettingsForm } from "./board-settings-form";
|
||||
import { BrandingSettingsForm } from "./branding-settings-form";
|
||||
import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings";
|
||||
import { CultureSettingsForm } from "./culture-settings-form";
|
||||
import { FeatureControlsSettingsForm } from "./feature-controls-settings-form";
|
||||
import { SearchSettingsForm } from "./search-settings-form";
|
||||
import { UserSettingsForm } from "./user-settings-form";
|
||||
|
||||
@@ -41,7 +40,6 @@ const settingsFormSchema = z.object({
|
||||
defaultColorScheme: z.enum(colorSchemes),
|
||||
defaultLocale: z.string(),
|
||||
branding: brandingServerSettingsSchema,
|
||||
featureControls: featureControlsServerSettingsSchema,
|
||||
});
|
||||
|
||||
export type FormValues = z.infer<typeof settingsFormSchema>;
|
||||
@@ -61,7 +59,6 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
|
||||
defaultColorScheme: initialData.appearance.defaultColorScheme,
|
||||
defaultLocale: initialData.culture.defaultLocale,
|
||||
branding: initialData.branding,
|
||||
featureControls: initialData.featureControls,
|
||||
});
|
||||
|
||||
interface SettingsFormProps {
|
||||
@@ -149,11 +146,6 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
|
||||
when: changed("branding"),
|
||||
value: values.branding,
|
||||
},
|
||||
{
|
||||
settingsKey: "featureControls",
|
||||
when: changed("featureControls"),
|
||||
value: values.featureControls,
|
||||
},
|
||||
];
|
||||
|
||||
const promises = groups
|
||||
@@ -194,7 +186,6 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
|
||||
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
|
||||
<AppearanceSettingsForm form={form} />
|
||||
<BrandingSettingsForm form={form} />
|
||||
<FeatureControlsSettingsForm form={form} />
|
||||
<CultureSettingsForm form={form} />
|
||||
<AnalyticsSettings form={form} />
|
||||
<CrawlingAndIndexingSettings form={form} />
|
||||
|
||||
+15
-47
@@ -65,8 +65,7 @@
|
||||
}
|
||||
|
||||
.sortableStrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
display: flex;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
@@ -79,7 +78,8 @@
|
||||
display: grid;
|
||||
grid-template-rows: 1.25rem 2.5rem;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
min-width: max-content;
|
||||
flex: 1 0 0;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
@@ -90,8 +90,8 @@
|
||||
min-width: 0;
|
||||
min-height: 1.25rem;
|
||||
padding-inline: 0.15rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
transition: background-color 120ms ease;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.zoneDelimiter :global(.mantine-Divider-label) {
|
||||
@@ -103,14 +103,6 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.zoneDelimiter[data-over="true"] {
|
||||
background: var(--mantine-primary-color-light);
|
||||
}
|
||||
|
||||
.zoneDelimiter[data-over="true"] :global(.mantine-Divider-label) {
|
||||
color: var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.zoneControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -118,13 +110,7 @@
|
||||
min-width: 0;
|
||||
height: 2.5rem;
|
||||
padding: 0.125rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.zoneControls::-webkit-scrollbar {
|
||||
display: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.zoneSegment[data-zone="center"] .zoneControls {
|
||||
@@ -135,31 +121,31 @@
|
||||
justify-content: safe flex-end;
|
||||
}
|
||||
|
||||
.zoneTail {
|
||||
.emptyZoneTarget {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.5rem;
|
||||
min-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
flex: 0 0 1.5rem;
|
||||
flex: 1 0 2.25rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.zoneTailMarker {
|
||||
.emptyZoneTarget span {
|
||||
width: 2px;
|
||||
height: 1rem;
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
background: var(--mantine-color-default-border);
|
||||
background: transparent;
|
||||
transition:
|
||||
height 120ms ease,
|
||||
background-color 120ms ease;
|
||||
}
|
||||
|
||||
.zoneTail[data-over="true"] {
|
||||
.emptyZoneTarget[data-over="true"] {
|
||||
background: var(--mantine-primary-color-light);
|
||||
}
|
||||
|
||||
.zoneTail[data-over="true"] .zoneTailMarker {
|
||||
.emptyZoneTarget[data-over="true"] span {
|
||||
height: 1.5rem;
|
||||
background: var(--mantine-primary-color-filled);
|
||||
}
|
||||
@@ -257,23 +243,6 @@
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.contextActions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
height: 2.25rem;
|
||||
flex: 0 0 auto;
|
||||
padding-inline: 0.35rem;
|
||||
}
|
||||
|
||||
.contextActions span {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 50%;
|
||||
background: var(--mantine-color-dimmed);
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.hiddenPreview {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
@@ -333,9 +302,8 @@
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.device,
|
||||
.zoneDelimiter,
|
||||
.zoneTail,
|
||||
.zoneTailMarker,
|
||||
.emptyZoneTarget,
|
||||
.emptyZoneTarget span,
|
||||
.itemButton {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
+63
-87
@@ -15,7 +15,6 @@ import {
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
@@ -44,6 +43,7 @@ import {
|
||||
IconEyeOff,
|
||||
IconHome,
|
||||
IconLayoutDashboard,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
IconReplace,
|
||||
IconRobot,
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type {
|
||||
HeaderBuiltinItemId,
|
||||
@@ -73,6 +72,7 @@ import {
|
||||
getHeaderItemZone,
|
||||
headerBuiltinItemIds,
|
||||
headerZoneIds,
|
||||
isRequiredHeaderItem,
|
||||
} from "@homarr/validation/user";
|
||||
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
@@ -94,7 +94,6 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
|
||||
const [previewSize, setPreviewSize] = useState<PreviewSize>("desktop");
|
||||
const [draggedItemKey, setDraggedItemKey] = useState<string | null>(null);
|
||||
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
|
||||
const { featureControls } = useSettings();
|
||||
const t = useI18n("management.page.user.setting.general.header");
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
|
||||
@@ -106,12 +105,11 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
|
||||
);
|
||||
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
|
||||
const activeItems = getHeaderItems(value.zones);
|
||||
const visibleItems = activeItems.filter((item) => isHeaderItemAvailable(item, featureControls));
|
||||
const visibleItemKeys = visibleItems.map(getHeaderItemKey);
|
||||
const visibleItemKeys = activeItems.map(getHeaderItemKey);
|
||||
const activeItemKeySet = new Set(activeItems.map(getHeaderItemKey));
|
||||
const inactiveBuiltinIds = headerBuiltinItemIds.filter((itemId) => {
|
||||
const item = createBuiltinHeaderItem(itemId);
|
||||
return isHeaderItemAvailable(item, featureControls) && !activeItemKeySet.has(getHeaderItemKey(item));
|
||||
return !isRequiredHeaderItem(item) && !activeItemKeySet.has(getHeaderItemKey(item));
|
||||
});
|
||||
const availableBoards = boards.filter(
|
||||
(board) => !activeItemKeySet.has(getHeaderItemKey(createBoardHeaderItem(board.id))),
|
||||
@@ -130,7 +128,7 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
|
||||
const updateZones = (zones: HeaderZones) => onChange({ ...value, zones });
|
||||
|
||||
const removeItem = (item: HeaderItem) => {
|
||||
if (item.type === "builtin" && item.id === "user") return;
|
||||
if (isRequiredHeaderItem(item)) return;
|
||||
const zone = getHeaderItemZone(value.zones, item);
|
||||
if (!zone) return;
|
||||
const itemKey = getHeaderItemKey(item);
|
||||
@@ -159,10 +157,14 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
|
||||
if (!sourceZone || !targetZone) return;
|
||||
|
||||
const targetItem = findHeaderItem(activeItems, over.id);
|
||||
let targetIndex = 0;
|
||||
if (getDropZoneTailId(targetZone) === over.id) targetIndex = value.zones[targetZone].length;
|
||||
else if (targetItem)
|
||||
let targetIndex = value.zones[targetZone].length;
|
||||
if (targetItem) {
|
||||
targetIndex = value.zones[targetZone].findIndex((candidate) => isSameItem(candidate, targetItem));
|
||||
const translatedRect = active.rect.current.translated;
|
||||
const activeCenter = translatedRect ? translatedRect.left + translatedRect.width / 2 : null;
|
||||
const targetCenter = over.rect.left + over.rect.width / 2;
|
||||
if (activeCenter !== null && activeCenter > targetCenter) targetIndex += 1;
|
||||
}
|
||||
if (targetIndex < 0) return;
|
||||
updateZones(moveHeaderItem(value.zones, item, targetZone, targetIndex));
|
||||
};
|
||||
@@ -227,10 +229,9 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
|
||||
key={zone}
|
||||
zone={zone}
|
||||
label={t(`zones.${zone}` as never)}
|
||||
endLabel={t("action.dropAtEnd", { zone: t(`zones.${zone}` as never) })}
|
||||
emptyLabel={t("action.dropIntoZone", { zone: t(`zones.${zone}` as never) })}
|
||||
leading={zone === "left" ? <MobileBurger /> : null}
|
||||
trailing={zone === "right" ? <ContextActions /> : null}
|
||||
items={value.zones[zone].filter((item) => isHeaderItemAvailable(item, featureControls))}
|
||||
items={value.zones[zone]}
|
||||
previewSize={previewSize}
|
||||
searchDisplay={value.searchDisplay}
|
||||
selectedItemKey={selectedItemKey}
|
||||
@@ -302,7 +303,7 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{selectedItem.type !== "builtin" || selectedItem.id !== "user" ? (
|
||||
{!isRequiredHeaderItem(selectedItem) ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
@@ -314,7 +315,7 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
|
||||
</Button>
|
||||
) : (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("available.accountAlways")}
|
||||
{t("available.requiredAlways")}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
@@ -393,9 +394,8 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
|
||||
interface ZoneSegmentProps {
|
||||
zone: HeaderZoneId;
|
||||
label: string;
|
||||
endLabel: string;
|
||||
emptyLabel: string;
|
||||
leading: ReactNode;
|
||||
trailing: ReactNode;
|
||||
items: HeaderItem[];
|
||||
previewSize: PreviewSize;
|
||||
searchDisplay: HeaderPreferences["searchDisplay"];
|
||||
@@ -409,9 +409,8 @@ interface ZoneSegmentProps {
|
||||
const ZoneSegment = ({
|
||||
zone,
|
||||
label,
|
||||
endLabel,
|
||||
emptyLabel,
|
||||
leading,
|
||||
trailing,
|
||||
items,
|
||||
previewSize,
|
||||
searchDisplay,
|
||||
@@ -441,29 +440,25 @@ const ZoneSegment = ({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<ZoneTail zone={zone} label={endLabel} />
|
||||
{trailing}
|
||||
{items.length === 0 ? <EmptyZoneDropTarget zone={zone} label={emptyLabel} /> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const ZoneDelimiter = ({ zone, label }: { zone: HeaderZoneId; label: string }) => {
|
||||
// Fixed reference point inside the shared sortable row; it never enters SortableContext.
|
||||
const ZoneDelimiter = ({ zone, label }: { zone: HeaderZoneId; label: string }) => (
|
||||
<div className={classes.zoneDelimiter} data-zone={zone}>
|
||||
<Divider label={label} labelPosition="center" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const EmptyZoneDropTarget = ({ zone, label }: { zone: HeaderZoneId; label: string }) => {
|
||||
const { isOver, setNodeRef } = useDroppable({ id: getDropZoneId(zone) });
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className={classes.zoneDelimiter} data-zone={zone} data-over={isOver || undefined}>
|
||||
<Divider label={label} labelPosition="center" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ZoneTail = ({ zone, label }: { zone: HeaderZoneId; label: string }) => {
|
||||
const { isOver, setNodeRef } = useDroppable({ id: getDropZoneTailId(zone) });
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className={classes.zoneTail} data-over={isOver || undefined}>
|
||||
<div ref={setNodeRef} className={classes.emptyZoneTarget} data-over={isOver || undefined}>
|
||||
<VisuallyHidden>{label}</VisuallyHidden>
|
||||
<span className={classes.zoneTailMarker} aria-hidden />
|
||||
<span aria-hidden />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -476,13 +471,6 @@ const MobileBurger = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const ContextActions = () => (
|
||||
<div className={classes.contextActions} aria-hidden>
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
);
|
||||
|
||||
interface SortableHeaderItemProps {
|
||||
item: HeaderItem;
|
||||
label: string;
|
||||
@@ -516,32 +504,30 @@ const SortableHeaderItem = ({
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={classes.sortableItem} data-selected={selected || undefined}>
|
||||
<Tooltip label={configureLabel} openDelay={500}>
|
||||
<button
|
||||
ref={setActivatorNodeRef}
|
||||
type="button"
|
||||
className={classes.itemButton}
|
||||
data-kind={item.type === "builtin" ? item.id : "board"}
|
||||
data-compact={previewSize === "mobile" || undefined}
|
||||
data-wide={
|
||||
item.type === "builtin" && item.id === "search" && searchDisplay === "input" && previewSize === "desktop"
|
||||
? true
|
||||
: undefined
|
||||
}
|
||||
aria-label={configureLabel}
|
||||
onClick={() => onSelect(itemKey)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<HeaderItemPreview
|
||||
item={item}
|
||||
label={label}
|
||||
compact={previewSize === "mobile"}
|
||||
searchDisplay={searchDisplay}
|
||||
boardLogo={boardLogo}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
ref={setActivatorNodeRef}
|
||||
type="button"
|
||||
className={classes.itemButton}
|
||||
data-kind={item.type === "builtin" ? item.id : "board"}
|
||||
data-compact={previewSize === "mobile" || undefined}
|
||||
data-wide={
|
||||
item.type === "builtin" && item.id === "search" && searchDisplay === "input" && previewSize === "desktop"
|
||||
? true
|
||||
: undefined
|
||||
}
|
||||
aria-label={configureLabel}
|
||||
onClick={() => onSelect(itemKey)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<HeaderItemPreview
|
||||
item={item}
|
||||
label={label}
|
||||
compact={previewSize === "mobile"}
|
||||
searchDisplay={searchDisplay}
|
||||
boardLogo={boardLogo}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -628,27 +614,25 @@ const moveHeaderItem = (
|
||||
const sourceIndex = sourceItems.findIndex((candidate) => isSameItem(candidate, item));
|
||||
if (sourceIndex < 0) return zones;
|
||||
|
||||
if (sourceZone === targetZone) {
|
||||
const normalizedTargetIndex = Math.min(targetIndex, sourceItems.length - 1);
|
||||
if (sourceIndex === normalizedTargetIndex) return zones;
|
||||
return { ...zones, [sourceZone]: arrayMove(sourceItems, sourceIndex, normalizedTargetIndex) };
|
||||
}
|
||||
const sourceItemsWithoutMovedItem = sourceItems.filter((candidate) => !isSameItem(candidate, item));
|
||||
const targetItems = sourceZone === targetZone ? sourceItemsWithoutMovedItem : zones[targetZone];
|
||||
let normalizedTargetIndex = Math.min(Math.max(targetIndex, 0), targetItems.length);
|
||||
if (sourceZone === targetZone && sourceIndex < targetIndex) normalizedTargetIndex -= 1;
|
||||
if (sourceZone === targetZone && sourceIndex === normalizedTargetIndex) return zones;
|
||||
|
||||
const targetItems = zones[targetZone];
|
||||
return {
|
||||
...zones,
|
||||
[sourceZone]: sourceItems.filter((candidate) => !isSameItem(candidate, item)),
|
||||
[targetZone]: [...targetItems.slice(0, targetIndex), item, ...targetItems.slice(targetIndex)],
|
||||
[sourceZone]: sourceItemsWithoutMovedItem,
|
||||
[targetZone]: [...targetItems.slice(0, normalizedTargetIndex), item, ...targetItems.slice(normalizedTargetIndex)],
|
||||
};
|
||||
};
|
||||
|
||||
const getDropZoneId = (zone: HeaderZoneId) => `header-zone-${zone}`;
|
||||
const getDropZoneTailId = (zone: HeaderZoneId) => `header-zone-${zone}-end`;
|
||||
|
||||
const getDropZone = (zones: HeaderZones, id: UniqueIdentifier): HeaderZoneId | undefined => {
|
||||
const item = findHeaderItem(getHeaderItems(zones), id);
|
||||
if (item) return getHeaderItemZone(zones, item);
|
||||
return headerZoneIds.find((zone) => getDropZoneId(zone) === id || getDropZoneTailId(zone) === id);
|
||||
return headerZoneIds.find((zone) => getDropZoneId(zone) === id);
|
||||
};
|
||||
|
||||
const findHeaderItem = (items: HeaderItem[], id: UniqueIdentifier | null): HeaderItem | undefined => {
|
||||
@@ -659,22 +643,14 @@ const findHeaderItem = (items: HeaderItem[], id: UniqueIdentifier | null): Heade
|
||||
|
||||
const isSameItem = (first: HeaderItem, second: HeaderItem) => getHeaderItemKey(first) === getHeaderItemKey(second);
|
||||
|
||||
const isHeaderItemAvailable = (
|
||||
item: HeaderItem,
|
||||
featureControls: { assistantEnabled: boolean; boardSwitcherEnabled: boolean },
|
||||
) => {
|
||||
if (item.type === "board") return true;
|
||||
if (item.id === "assistant") return featureControls.assistantEnabled;
|
||||
if (item.id === "boardSwitcher") return featureControls.boardSwitcherEnabled;
|
||||
return true;
|
||||
};
|
||||
|
||||
const getBuiltinItemIcon = (itemId: HeaderBuiltinItemId) => {
|
||||
if (itemId === "search") return IconSearch;
|
||||
if (itemId === "home") return IconHome;
|
||||
if (itemId === "boardSwitcher") return IconReplace;
|
||||
if (itemId === "assistant") return IconRobot;
|
||||
if (itemId === "docker") return IconBrandDocker;
|
||||
if (itemId === "boardEdit") return IconPencil;
|
||||
if (itemId === "boardSettings") return IconSettings;
|
||||
if (itemId === "settings") return IconSettings;
|
||||
if (itemId === "themeToggle") return IconSunMoon;
|
||||
if (itemId === "user") return IconUserCircle;
|
||||
|
||||
@@ -18,7 +18,6 @@ import { decryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { assistantConfigurations, assistantThreads } from "@homarr/db/schema";
|
||||
import {
|
||||
assistantProviderCanUseOpenRouterServerTools,
|
||||
@@ -374,11 +373,6 @@ export async function POST(request: Request) {
|
||||
return Response.json({ error: "Sign in to use Assistant." }, { status: 401 });
|
||||
}
|
||||
|
||||
const featureControls = await getServerSettingByKeyAsync(db, "featureControls");
|
||||
if (!featureControls.assistantEnabled) {
|
||||
return Response.json({ error: "Assistant is disabled." }, { status: 404 });
|
||||
}
|
||||
|
||||
const requestBody = await request.text();
|
||||
if (requestBody.length > assistantRequestMaxBytes) {
|
||||
return Response.json({ error: "The assistant request is too large." }, { status: 413 });
|
||||
|
||||
@@ -23,16 +23,8 @@ const EnabledAssistantRoot = dynamic(() =>
|
||||
import("./assistant-provider").then((module) => ({ default: module.EnabledAssistantRoot })),
|
||||
);
|
||||
|
||||
const AssistantSpotlightPlaceholder = ({ description }: { description: string }) => {
|
||||
const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => {
|
||||
useRegisterAssistantSpotlightPlaceholder(description);
|
||||
return null;
|
||||
};
|
||||
|
||||
const DisabledAssistant = ({
|
||||
children,
|
||||
description,
|
||||
discoverable = true,
|
||||
}: PropsWithChildren<{ description: string; discoverable?: boolean }>) => {
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
enabled: false,
|
||||
@@ -56,7 +48,6 @@ const DisabledAssistant = ({
|
||||
|
||||
return (
|
||||
<AssistantContext.Provider value={value}>
|
||||
{discoverable ? <AssistantSpotlightPlaceholder description={description} /> : null}
|
||||
<AssistantWidgetRendererProvider renderer={AssistantBoardWidgetLazy}>{children}</AssistantWidgetRendererProvider>
|
||||
</AssistantContext.Provider>
|
||||
);
|
||||
@@ -68,7 +59,7 @@ const DisabledAssistant = ({
|
||||
* `unauthenticated` is kept apart from `unconfigured` because the server cannot check availability
|
||||
* for a signed-out visitor, and telling them the instance is unconfigured would be a guess.
|
||||
*/
|
||||
export type AssistantAvailability = "enabled" | "unconfigured" | "unauthenticated" | "disabled" | "error";
|
||||
export type AssistantAvailability = "enabled" | "unconfigured" | "unauthenticated" | "error";
|
||||
|
||||
interface AssistantGateProps extends PropsWithChildren {
|
||||
availability: AssistantAvailability;
|
||||
@@ -87,13 +78,5 @@ export const AssistantGate = ({ availability, children }: AssistantGateProps) =>
|
||||
return <EnabledAssistantRoot>{children}</EnabledAssistantRoot>;
|
||||
}
|
||||
|
||||
if (availability === "disabled") {
|
||||
return (
|
||||
<DisabledAssistant description={t("unavailable.disabledByServer")} discoverable={false}>
|
||||
{children}
|
||||
</DisabledAssistant>
|
||||
);
|
||||
}
|
||||
|
||||
return <DisabledAssistant description={t(unavailableMessageKeys[availability])}>{children}</DisabledAssistant>;
|
||||
};
|
||||
@@ -36,18 +36,16 @@ import classes from "./board-switcher.module.css";
|
||||
export const boardSwitcherHotkey = "shift+c";
|
||||
|
||||
export interface BoardSwitcherControls {
|
||||
enabled: boolean;
|
||||
open: () => void;
|
||||
preload: () => void;
|
||||
hotkey: string;
|
||||
}
|
||||
|
||||
interface BoardSwitcherProps {
|
||||
enabled?: boolean;
|
||||
children: (controls: BoardSwitcherControls) => ReactNode;
|
||||
}
|
||||
|
||||
export const BoardSwitcher = ({ enabled = true, children }: BoardSwitcherProps) => {
|
||||
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
const t = useI18n("board.action.switcher");
|
||||
const manageBoardsT = useI18n("management.page.board");
|
||||
const currentBoard = useOptionalBoard();
|
||||
@@ -64,7 +62,7 @@ export const BoardSwitcher = ({ enabled = true, children }: BoardSwitcherProps)
|
||||
} = clientApi.board.getManageOverview.useQuery(
|
||||
{ fullPreview: true },
|
||||
{
|
||||
enabled: enabled && isOpen,
|
||||
enabled: isOpen,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -84,24 +82,16 @@ export const BoardSwitcher = ({ enabled = true, children }: BoardSwitcherProps)
|
||||
const modalColumnCount = Math.max(1, Math.min(switcherBoards.length, responsiveColumnCount));
|
||||
|
||||
const openSwitcher = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
setIsOpen(true);
|
||||
}, [enabled]);
|
||||
}, []);
|
||||
const closeSwitcher = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setSearch("");
|
||||
setActiveIndex(0);
|
||||
}, []);
|
||||
const preloadBoards = () => {
|
||||
if (!enabled) return;
|
||||
void utils.board.getManageOverview.prefetch({ fullPreview: true });
|
||||
};
|
||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
|
||||
|
||||
useHotkeys(enabled ? [[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]] : []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled && isOpen) closeSwitcher();
|
||||
}, [closeSwitcher, enabled, isOpen]);
|
||||
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || filteredBoards.length === 0) return;
|
||||
@@ -186,10 +176,10 @@ export const BoardSwitcher = ({ enabled = true, children }: BoardSwitcherProps)
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({ enabled, open: openSwitcher, preload: preloadBoards, hotkey: boardSwitcherHotkey })}
|
||||
{children({ open: openSwitcher, preload: preloadBoards, hotkey: boardSwitcherHotkey })}
|
||||
|
||||
<Modal
|
||||
opened={enabled && isOpen}
|
||||
opened={isOpen}
|
||||
onClose={closeSwitcher}
|
||||
withCloseButton={false}
|
||||
yOffset="15vh"
|
||||
|
||||
@@ -222,13 +222,7 @@ export const WidgetContextMenu = ({
|
||||
[setItemOptions],
|
||||
);
|
||||
|
||||
if (
|
||||
!session ||
|
||||
!settings.featureControls.widgetContextMenuEnabled ||
|
||||
!settings.enableRightClickOnWidgets ||
|
||||
isEditMode ||
|
||||
item.kind === "app"
|
||||
) {
|
||||
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,18 @@ import { HomarrLogo } from "./logo/homarr-logo";
|
||||
interface Props {
|
||||
logo?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
boardEditAction?: ReactNode;
|
||||
boardSettingsAction?: ReactNode;
|
||||
hasNavigation?: boolean;
|
||||
}
|
||||
|
||||
export const MainHeader = async ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
export const MainHeader = async ({
|
||||
logo,
|
||||
actions,
|
||||
boardEditAction,
|
||||
boardSettingsAction,
|
||||
hasNavigation = true,
|
||||
}: Props) => {
|
||||
const session = await auth();
|
||||
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
|
||||
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||
@@ -22,6 +30,8 @@ export const MainHeader = async ({ logo, actions, hasNavigation = true }: Props)
|
||||
<ConfigurableHeader
|
||||
logo={logo ?? <HomarrLogo size={appShellLogoHeight} />}
|
||||
actions={actions}
|
||||
boardEditAction={boardEditAction}
|
||||
boardSettingsAction={boardSettingsAction}
|
||||
hasNavigation={hasNavigation}
|
||||
avatar={<CurrentUserAvatar size="md" />}
|
||||
userId={session?.user.id ?? null}
|
||||
|
||||
@@ -41,10 +41,13 @@
|
||||
.logo {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: auto;
|
||||
height: calc(100% - 0.5rem);
|
||||
height: calc(var(--app-shell-header-height) - 0.5rem);
|
||||
max-height: calc(var(--app-shell-header-height) - 0.5rem);
|
||||
max-width: min(12rem, 28vw);
|
||||
flex: 0 0 auto;
|
||||
flex: 0 1 auto;
|
||||
overflow: hidden;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
@@ -52,8 +55,9 @@
|
||||
.logo :global(img.logo) {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: calc(var(--app-shell-header-height) - 0.5rem);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ import classes from "./configurable-header.module.css";
|
||||
interface ConfigurableHeaderProps {
|
||||
logo: ReactNode;
|
||||
actions?: ReactNode;
|
||||
boardEditAction?: ReactNode;
|
||||
boardSettingsAction?: ReactNode;
|
||||
hasNavigation: boolean;
|
||||
avatar: ReactNode;
|
||||
userId: string | null;
|
||||
@@ -58,15 +60,15 @@ type HeaderBoard = RouterOutputs["board"]["getAllBoards"][number];
|
||||
export const ConfigurableHeader = ({
|
||||
logo,
|
||||
actions,
|
||||
boardEditAction,
|
||||
boardSettingsAction,
|
||||
hasNavigation,
|
||||
avatar,
|
||||
userId,
|
||||
isAdmin,
|
||||
isDockerEnabled,
|
||||
}: ConfigurableHeaderProps) => {
|
||||
const { featureControls, headerPreferences } = useSettings();
|
||||
const assistantEnabled = featureControls.assistantEnabled;
|
||||
const boardSwitcherEnabled = featureControls.boardSwitcherEnabled;
|
||||
const { headerPreferences } = useSettings();
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||
@@ -80,7 +82,7 @@ export const ConfigurableHeader = ({
|
||||
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
|
||||
|
||||
return (
|
||||
<BoardSwitcher enabled={boardSwitcherEnabled}>
|
||||
<BoardSwitcher>
|
||||
{(boardSwitcher) => {
|
||||
const renderItems = (items: HeaderItem[]) =>
|
||||
items.map((item) => (
|
||||
@@ -88,12 +90,12 @@ export const ConfigurableHeader = ({
|
||||
key={getHeaderItemKey(item)}
|
||||
item={item}
|
||||
logo={logo}
|
||||
boardEditAction={boardEditAction}
|
||||
boardSettingsAction={boardSettingsAction}
|
||||
avatar={avatar}
|
||||
userId={userId}
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
assistantEnabled={assistantEnabled}
|
||||
boardSwitcherEnabled={boardSwitcherEnabled}
|
||||
board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
|
||||
searchDisplay={headerPreferences.searchDisplay}
|
||||
boardSwitcher={boardSwitcher}
|
||||
@@ -151,12 +153,12 @@ export const ConfigurableHeader = ({
|
||||
interface HeaderItemProps {
|
||||
item: HeaderItem;
|
||||
logo: ReactNode;
|
||||
boardEditAction: ReactNode;
|
||||
boardSettingsAction: ReactNode;
|
||||
avatar: ReactNode;
|
||||
userId: string | null;
|
||||
isAdmin: boolean;
|
||||
isDockerEnabled: boolean;
|
||||
assistantEnabled: boolean;
|
||||
boardSwitcherEnabled: boolean;
|
||||
board: HeaderBoard | undefined;
|
||||
searchDisplay: "input" | "icon";
|
||||
boardSwitcher: BoardSwitcherControls;
|
||||
@@ -170,12 +172,12 @@ interface HeaderItemProps {
|
||||
const HeaderItem = ({
|
||||
item,
|
||||
logo,
|
||||
boardEditAction,
|
||||
boardSettingsAction,
|
||||
avatar,
|
||||
userId,
|
||||
isAdmin,
|
||||
isDockerEnabled,
|
||||
assistantEnabled,
|
||||
boardSwitcherEnabled,
|
||||
board,
|
||||
searchDisplay,
|
||||
boardSwitcher,
|
||||
@@ -214,6 +216,9 @@ const HeaderItem = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (item.id === "boardEdit") return boardEditAction;
|
||||
if (item.id === "boardSettings") return boardSettingsAction;
|
||||
|
||||
if (item.id === "search") {
|
||||
return (
|
||||
<TourTarget id="board-search">
|
||||
@@ -242,7 +247,6 @@ const HeaderItem = ({
|
||||
}
|
||||
|
||||
if (item.id === "boardSwitcher") {
|
||||
if (!boardSwitcherEnabled) return null;
|
||||
return (
|
||||
<Tooltip label={label("boardSwitcher")}>
|
||||
<HeaderButton
|
||||
@@ -258,7 +262,7 @@ const HeaderItem = ({
|
||||
}
|
||||
|
||||
if (item.id === "assistant") {
|
||||
if (!assistantEnabled || !assistant?.enabled) return null;
|
||||
if (!assistant?.enabled) return null;
|
||||
return (
|
||||
<Tooltip label={label("assistant")}>
|
||||
<Indicator
|
||||
|
||||
@@ -67,17 +67,15 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
{boardSwitcher.enabled ? (
|
||||
<Menu.Item
|
||||
leftSection={<IconReplace size="1rem" />}
|
||||
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
|
||||
onClick={boardSwitcher.open}
|
||||
onFocus={boardSwitcher.preload}
|
||||
onPointerEnter={boardSwitcher.preload}
|
||||
>
|
||||
{tBoard("action.switch")}
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
<Menu.Item
|
||||
leftSection={<IconReplace size="1rem" />}
|
||||
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
|
||||
onClick={boardSwitcher.open}
|
||||
onFocus={boardSwitcher.preload}
|
||||
onPointerEnter={boardSwitcher.preload}
|
||||
>
|
||||
{tBoard("action.switch")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
items,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
assistantProviderCanUseOpenRouterServerTools,
|
||||
assistantProviderIds,
|
||||
@@ -38,13 +37,6 @@ import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedPro
|
||||
import { boardRouter, getHomeIdBoardAsync } from "./board";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
const assistantEnabledProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
||||
const featureControls = await getServerSettingByKeyAsync(ctx.db, "featureControls");
|
||||
if (!featureControls.assistantEnabled) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Assistant is disabled." });
|
||||
}
|
||||
return next({ ctx });
|
||||
});
|
||||
const configurationId = "default";
|
||||
const providerSchema = z.enum(assistantProviderIds);
|
||||
|
||||
@@ -461,8 +453,6 @@ const addFeedbackToMessageContent = (serializedContent: string, type: "positive"
|
||||
|
||||
export const assistantRouter = createTRPCRouter({
|
||||
getAvailability: protectedProcedure.query(async ({ ctx }) => {
|
||||
const featureControls = await getServerSettingByKeyAsync(ctx.db, "featureControls");
|
||||
if (!featureControls.assistantEnabled) return { enabled: false };
|
||||
if (isDemoMode) {
|
||||
return { enabled: true };
|
||||
}
|
||||
@@ -475,11 +465,11 @@ export const assistantRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
getContextEntities: assistantEnabledProcedure.query(async ({ ctx }) => {
|
||||
getContextEntities: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await getAssistantContextEntitiesAsync(ctx);
|
||||
}),
|
||||
|
||||
getModelCapabilities: assistantEnabledProcedure
|
||||
getModelCapabilities: protectedProcedure
|
||||
.input(z.object({ modelId: z.string().trim().min(1).max(256).optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
@@ -496,7 +486,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
getRuntimeOptions: assistantEnabledProcedure.query(async ({ ctx }) => {
|
||||
getRuntimeOptions: protectedProcedure.query(async ({ ctx }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
if (isDemoMode) {
|
||||
return {
|
||||
@@ -694,7 +684,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
|
||||
createThread: assistantEnabledProcedure
|
||||
createThread: protectedProcedure
|
||||
.input(z.object({ localId: z.string().max(128).optional() }).optional())
|
||||
.mutation(async ({ ctx }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
@@ -710,7 +700,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
return { id };
|
||||
}),
|
||||
|
||||
updateThreadModel: assistantEnabledProcedure
|
||||
updateThreadModel: protectedProcedure
|
||||
.input(z.object({ threadId: z.string().max(64), modelId: z.string().trim().min(1).max(256) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [thread, configuration] = await Promise.all([
|
||||
@@ -755,7 +745,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
getGenerationTelemetry: assistantEnabledProcedure
|
||||
getGenerationTelemetry: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
threadId: z.string().max(64),
|
||||
@@ -801,7 +791,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
renameThread: assistantEnabledProcedure
|
||||
renameThread: protectedProcedure
|
||||
.input(z.object({ threadId: z.string().max(64), title: z.string().trim().min(1).max(120) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
||||
@@ -818,7 +808,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
await ctx.db.delete(assistantThreads).where(eq(assistantThreads.id, input.threadId));
|
||||
}),
|
||||
|
||||
appendMessage: assistantEnabledProcedure
|
||||
appendMessage: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
threadId: z.string().max(64),
|
||||
@@ -879,7 +869,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
.where(eq(assistantThreads.id, thread.id));
|
||||
}),
|
||||
|
||||
submitFeedback: assistantEnabledProcedure
|
||||
submitFeedback: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
threadId: z.string().max(64),
|
||||
|
||||
@@ -14,9 +14,7 @@ import {
|
||||
authBrandingSchema,
|
||||
brandingServerSettingsSchema,
|
||||
defaultServerSettingsKeys,
|
||||
featureControlsServerSettingsSchema,
|
||||
parseBrandingSettings,
|
||||
parseFeatureControls,
|
||||
} from "@homarr/server-settings";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../trpc";
|
||||
@@ -37,8 +35,6 @@ const legacyAuthBrandingUpdateSchema = z.object({
|
||||
showCustomLogoOnLogin: z.boolean().optional(),
|
||||
showCustomGreetingOnLogin: z.boolean().optional(),
|
||||
});
|
||||
const featureControlsServerSettingsUpdateSchema = featureControlsServerSettingsSchema.partial();
|
||||
|
||||
export const serverSettingsRouter = createTRPCRouter({
|
||||
getCulture: publicProcedure.query(async ({ ctx }) => {
|
||||
return await getServerSettingByKeyAsync(ctx.db, "culture");
|
||||
@@ -137,14 +133,6 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
await updateServerSettingByKeyAsync(ctx.db, "branding", value);
|
||||
return;
|
||||
}
|
||||
if (input.settingsKey === "featureControls") {
|
||||
const current = await getServerSettingByKeyAsync(ctx.db, "featureControls");
|
||||
const parsedInput = featureControlsServerSettingsUpdateSchema.parse(input.value);
|
||||
const value = parseFeatureControls({ ...current, ...parsedInput });
|
||||
await updateServerSettingByKeyAsync(ctx.db, "featureControls", value);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
|
||||
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
|
||||
...current,
|
||||
|
||||
@@ -1 +1 @@
|
||||
ALTER TABLE `user` ADD `header_preferences` text DEFAULT ('{"version":3,"visible":true,"searchDisplay":"input","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"user"}]}}') NOT NULL;
|
||||
ALTER TABLE `user` ADD `header_preferences` text DEFAULT ('{"version":3,"visible":true,"searchDisplay":"input","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"user"},{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"}]}}') NOT NULL;
|
||||
@@ -1,2 +0,0 @@
|
||||
INSERT IGNORE INTO `serverSetting` (`setting_key`, `value`)
|
||||
VALUES ('featureControls', '{"json":{"assistantEnabled":true,"boardSwitcherEnabled":true,"widgetContextMenuEnabled":true}}');
|
||||
@@ -2656,7 +2656,7 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "('{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"}]}}')"
|
||||
"default": "('{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"},{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"}]}}')"
|
||||
},
|
||||
"completed_manage_tour": {
|
||||
"name": "completed_manage_tour",
|
||||
|
||||
@@ -2656,7 +2656,7 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "('{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"}]}}')"
|
||||
"default": "('{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"},{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"}]}}')"
|
||||
},
|
||||
"completed_manage_tour": {
|
||||
"name": "completed_manage_tour",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -351,13 +351,6 @@
|
||||
"when": 1787496053718,
|
||||
"tag": "0049_add_server_branding_settings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 50,
|
||||
"version": "5",
|
||||
"when": 1787496060001,
|
||||
"tag": "0050_add_feature_controls_server_setting",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
ALTER TABLE "user" ADD COLUMN "header_preferences" text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"user"}]}}' NOT NULL;
|
||||
ALTER TABLE "user" ADD COLUMN "header_preferences" text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"user"},{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"}]}}' NOT NULL;
|
||||
@@ -1,3 +0,0 @@
|
||||
INSERT INTO "serverSetting" ("setting_key", "value")
|
||||
VALUES ('featureControls', '{"json":{"assistantEnabled":true,"boardSwitcherEnabled":true,"widgetContextMenuEnabled":true}}')
|
||||
ON CONFLICT ("setting_key") DO NOTHING;
|
||||
@@ -2521,7 +2521,7 @@
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
|
||||
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"},{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"}]}}'"
|
||||
},
|
||||
"completed_manage_tour": {
|
||||
"name": "completed_manage_tour",
|
||||
|
||||
@@ -2521,7 +2521,7 @@
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
|
||||
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"},{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"}]}}'"
|
||||
},
|
||||
"completed_manage_tour": {
|
||||
"name": "completed_manage_tour",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -113,13 +113,6 @@
|
||||
"when": 1787496053659,
|
||||
"tag": "0015_add_server_branding_settings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1787496060002,
|
||||
"tag": "0016_add_feature_controls_server_setting",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
ALTER TABLE `user` ADD `header_preferences` text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"user"}]}}' NOT NULL;
|
||||
ALTER TABLE `user` ADD `header_preferences` text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"user"},{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"}]}}' NOT NULL;
|
||||
@@ -1,3 +0,0 @@
|
||||
INSERT INTO `serverSetting` (`setting_key`, `value`)
|
||||
VALUES ('featureControls', '{"json":{"assistantEnabled":true,"boardSwitcherEnabled":true,"widgetContextMenuEnabled":true}}')
|
||||
ON CONFLICT (`setting_key`) DO NOTHING;
|
||||
@@ -2551,7 +2551,7 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
|
||||
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"},{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"}]}}'"
|
||||
},
|
||||
"completed_manage_tour": {
|
||||
"name": "completed_manage_tour",
|
||||
|
||||
@@ -2551,7 +2551,7 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
|
||||
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"},{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"}]}}'"
|
||||
},
|
||||
"completed_manage_tour": {
|
||||
"name": "completed_manage_tour",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -337,13 +337,6 @@
|
||||
"when": 1787496053654,
|
||||
"tag": "0047_add_server_branding_settings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 48,
|
||||
"version": "6",
|
||||
"when": 1787496060000,
|
||||
"tag": "0048_add_feature_controls_server_setting",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import {
|
||||
defaultServerSettings,
|
||||
defaultServerSettingsKeys,
|
||||
parseBrandingSettings,
|
||||
parseFeatureControls,
|
||||
} from "@homarr/server-settings";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys, parseBrandingSettings } from "@homarr/server-settings";
|
||||
|
||||
import type { Database } from "..";
|
||||
import { eq } from "..";
|
||||
@@ -28,11 +23,6 @@ export const getServerSettingsAsync = async (db: Database) => {
|
||||
acc[settingKey] = parseBrandingSettings(parsedSetting) as never;
|
||||
return acc;
|
||||
}
|
||||
if (settingKey === "featureControls") {
|
||||
acc[settingKey] = parseFeatureControls(parsedSetting) as never;
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[settingKey] = {
|
||||
...defaultServerSettings[settingKey],
|
||||
...parsedSetting,
|
||||
@@ -54,10 +44,6 @@ export const getServerSettingByKeyAsync = async <TKey extends keyof ServerSettin
|
||||
if (key === "branding") {
|
||||
return parseBrandingSettings(parsedSetting) as ServerSettings[TKey];
|
||||
}
|
||||
if (key === "featureControls") {
|
||||
return parseFeatureControls(parsedSetting) as ServerSettings[TKey];
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultServerSettings[key],
|
||||
...parsedSetting,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEventHandler, KeyboardEventHandler } from "react";
|
||||
import type { FocusEventHandler, KeyboardEventHandler, ReactNode } from "react";
|
||||
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -48,6 +48,7 @@ interface IconPickerProps {
|
||||
onFocus?: FocusEventHandler;
|
||||
onBlur?: FocusEventHandler;
|
||||
label?: string;
|
||||
description?: ReactNode;
|
||||
placeholder?: string;
|
||||
suggestedSearch?: string | null;
|
||||
withAsterisk?: boolean;
|
||||
@@ -63,6 +64,7 @@ export const IconPicker = ({
|
||||
onBlur,
|
||||
withAsterisk = true,
|
||||
label,
|
||||
description,
|
||||
placeholder,
|
||||
suggestedSearch,
|
||||
}: IconPickerProps) => {
|
||||
@@ -272,6 +274,7 @@ export const IconPicker = ({
|
||||
(query.data
|
||||
? tCommon("iconPicker.header", { countIcons: String(query.data.countIcons) })
|
||||
: tCommon("iconPicker.headerLoading"));
|
||||
const inputDescription = description === undefined ? tCommon("iconPicker.description") : description;
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
@@ -359,7 +362,7 @@ export const IconPicker = ({
|
||||
withAsterisk={withAsterisk}
|
||||
error={error}
|
||||
label={label ?? tCommon("iconPicker.label")}
|
||||
description={tCommon("iconPicker.description")}
|
||||
description={inputDescription}
|
||||
styles={{
|
||||
description: { color: "var(--mantine-color-text)" },
|
||||
input: { color: "var(--mantine-color-text)" },
|
||||
|
||||
@@ -9,7 +9,6 @@ export const defaultServerSettingsKeys = [
|
||||
"user",
|
||||
"appearance",
|
||||
"branding",
|
||||
"featureControls",
|
||||
"culture",
|
||||
"search",
|
||||
] as const;
|
||||
@@ -25,13 +24,6 @@ export const authBrandingSchema = z.object({
|
||||
showGreeting: z.boolean(),
|
||||
});
|
||||
|
||||
export const featureControlsServerSettingsSchema = z.object({
|
||||
assistantEnabled: z.boolean(),
|
||||
boardSwitcherEnabled: z.boolean(),
|
||||
widgetContextMenuEnabled: z.boolean(),
|
||||
});
|
||||
export type FeatureControlsSettings = z.infer<typeof featureControlsServerSettingsSchema>;
|
||||
|
||||
const brandingImageUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -81,12 +73,6 @@ export const defaultBrandingSettings: BrandingSettings = {
|
||||
defaultRadius: "md",
|
||||
};
|
||||
|
||||
export const defaultFeatureControls: FeatureControlsSettings = {
|
||||
assistantEnabled: true,
|
||||
boardSwitcherEnabled: true,
|
||||
widgetContextMenuEnabled: true,
|
||||
};
|
||||
|
||||
const legacyLoginBrandingSchema = z.object({
|
||||
showCustomAppNameOnLogin: z.boolean().optional(),
|
||||
showCustomLogoOnLogin: z.boolean().optional(),
|
||||
@@ -122,16 +108,6 @@ export const parseBrandingSettings = (value: unknown): BrandingSettings => {
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const parseFeatureControls = (value: unknown) => {
|
||||
const partialValue = z.record(z.string(), z.unknown()).safeParse(value);
|
||||
if (!partialValue.success) return defaultFeatureControls;
|
||||
|
||||
const result = featureControlsServerSettingsSchema.safeParse({ ...defaultFeatureControls, ...partialValue.data });
|
||||
if (!result.success) return defaultFeatureControls;
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const defaultServerSettings = {
|
||||
analytics: {
|
||||
enableGeneral: true,
|
||||
@@ -156,7 +132,6 @@ export const defaultServerSettings = {
|
||||
defaultColorScheme: "auto" as ColorScheme,
|
||||
},
|
||||
branding: defaultBrandingSettings,
|
||||
featureControls: defaultFeatureControls,
|
||||
culture: {
|
||||
defaultLocale: "en" as SupportedLanguage,
|
||||
},
|
||||
|
||||
@@ -24,7 +24,6 @@ export type SettingsContextProps = Omit<
|
||||
> &
|
||||
Pick<ServerSettings["user"], "enableGravatar"> & {
|
||||
branding: ServerSettings["branding"];
|
||||
featureControls: ServerSettings["featureControls"];
|
||||
};
|
||||
|
||||
export interface PublicServerSettings {
|
||||
@@ -35,7 +34,6 @@ export interface PublicServerSettings {
|
||||
>;
|
||||
user: Pick<ServerSettings["user"], "enableGravatar">;
|
||||
branding: ServerSettings["branding"];
|
||||
featureControls: ServerSettings["featureControls"];
|
||||
}
|
||||
|
||||
export type UserSettings = Pick<
|
||||
@@ -71,5 +69,4 @@ export const createSettings = ({
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
enableGravatar: serverSettings.user.enableGravatar,
|
||||
branding: parseBrandingSettings(serverSettings.branding),
|
||||
featureControls: serverSettings.featureControls,
|
||||
});
|
||||
@@ -5689,7 +5689,7 @@
|
||||
"header": {
|
||||
"title": "Header studio",
|
||||
"badge": "Personal",
|
||||
"description": "Drag each control across the fixed Left, Center, and Right dividers. Click a control to configure it.",
|
||||
"description": "This is one continuous row. Drag controls anywhere; Left, Center, and Right are fixed dividers and never move.",
|
||||
"device": {
|
||||
"desktop": "Desktop",
|
||||
"mobile": "Mobile"
|
||||
@@ -5704,7 +5704,7 @@
|
||||
"show": "Show the header",
|
||||
"configure": "Move or configure {item}",
|
||||
"remove": "Remove {item}",
|
||||
"dropAtEnd": "Drop at the end of the {zone} zone"
|
||||
"dropIntoZone": "Move into the {zone} section"
|
||||
},
|
||||
"preview": {
|
||||
"board": "Dashboard preview for {name}",
|
||||
@@ -5713,7 +5713,7 @@
|
||||
"available": {
|
||||
"title": "Available controls",
|
||||
"description": "Add a control, then drag its full button into position above.",
|
||||
"accountAlways": "Account access always remains available",
|
||||
"requiredAlways": "This control is required, but you can move it anywhere in the header.",
|
||||
"allAdded": "Every built-in control is already in your header.",
|
||||
"add": "Add {item}",
|
||||
"boardShortcut": "Board shortcut",
|
||||
@@ -5733,6 +5733,8 @@
|
||||
"boardSwitcher": "Boards",
|
||||
"assistant": "Assistant",
|
||||
"docker": "Docker",
|
||||
"boardEdit": "Edit board",
|
||||
"boardSettings": "Board settings",
|
||||
"settings": "Your settings",
|
||||
"themeToggle": "Toggle theme",
|
||||
"user": "Account",
|
||||
@@ -6011,21 +6013,6 @@
|
||||
"off": "Off"
|
||||
}
|
||||
},
|
||||
"featureControls": {
|
||||
"title": "Feature controls",
|
||||
"assistant": {
|
||||
"label": "Assistant",
|
||||
"description": "Show and allow the instance Assistant."
|
||||
},
|
||||
"boardSwitcher": {
|
||||
"label": "Board switcher",
|
||||
"description": "Allow board switching from the header, account menu, and keyboard shortcut."
|
||||
},
|
||||
"widgetContextMenu": {
|
||||
"label": "Widget context menu",
|
||||
"description": "Allow widget actions from the context menu."
|
||||
}
|
||||
},
|
||||
"culture": {
|
||||
"title": "Culture",
|
||||
"defaultLocale": {
|
||||
@@ -7625,7 +7612,6 @@
|
||||
},
|
||||
"unavailable": {
|
||||
"checking": "Checking whether the assistant is available…",
|
||||
"disabledByServer": "An administrator has disabled Assistant for this server.",
|
||||
"error": "The assistant's availability could not be checked. Try again.",
|
||||
"notConfigured": "An administrator must configure and enable the assistant first.",
|
||||
"signIn": "Sign in to use Assistant."
|
||||
|
||||
@@ -9,11 +9,15 @@ export const headerBuiltinItemIds = [
|
||||
"boardSwitcher",
|
||||
"assistant",
|
||||
"docker",
|
||||
"boardEdit",
|
||||
"boardSettings",
|
||||
"settings",
|
||||
"themeToggle",
|
||||
"user",
|
||||
] as const;
|
||||
|
||||
export const requiredHeaderBuiltinItemIds = ["boardEdit", "boardSettings", "user"] as const;
|
||||
|
||||
export const headerPreferenceItemIds = headerBuiltinItemIds;
|
||||
export const headerItemIds = headerBuiltinItemIds;
|
||||
|
||||
@@ -52,6 +56,9 @@ export const createBuiltinHeaderItem = (id: HeaderBuiltinItemId): HeaderBuiltinI
|
||||
|
||||
export const createBoardHeaderItem = (boardId: string): HeaderBoardItem => ({ type: "board", boardId });
|
||||
|
||||
export const isRequiredHeaderItem = (item: HeaderItem) =>
|
||||
item.type === "builtin" && requiredHeaderBuiltinItemIds.some((itemId) => itemId === item.id);
|
||||
|
||||
export const getHeaderItemKey = (item: HeaderItem): string => {
|
||||
if (item.type === "board") return `board:${item.boardId}`;
|
||||
return `builtin:${item.id}`;
|
||||
@@ -165,6 +172,22 @@ const migrateLegacyHeaderPreferencesV1 = (
|
||||
const migrateLegacyHeaderPreferencesV2 = (legacy: z.infer<typeof legacyHeaderPreferencesV2Schema>): HeaderPreferences =>
|
||||
migrateLegacyItems(legacy.visible, legacy.zones);
|
||||
|
||||
const includeRequiredHeaderItems = (preferences: HeaderPreferences): HeaderPreferences => {
|
||||
const itemKeys = new Set(getHeaderItems(preferences.zones).map(getHeaderItemKey));
|
||||
const missingItems = requiredHeaderBuiltinItemIds
|
||||
.map(createBuiltinHeaderItem)
|
||||
.filter((item) => !itemKeys.has(getHeaderItemKey(item)));
|
||||
if (missingItems.length === 0) return preferences;
|
||||
|
||||
return {
|
||||
...preferences,
|
||||
zones: {
|
||||
...preferences.zones,
|
||||
right: [...preferences.zones.right, ...missingItems],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const headerPreferencesMutationSchema = z
|
||||
.discriminatedUnion("version", [
|
||||
headerPreferencesSchema,
|
||||
@@ -172,9 +195,9 @@ export const headerPreferencesMutationSchema = z
|
||||
legacyHeaderPreferencesV2Schema,
|
||||
])
|
||||
.transform((preferences): HeaderPreferences => {
|
||||
if (preferences.version === 1) return migrateLegacyHeaderPreferencesV1(preferences);
|
||||
if (preferences.version === 2) return migrateLegacyHeaderPreferencesV2(preferences);
|
||||
return preferences;
|
||||
if (preferences.version === 1) return includeRequiredHeaderItems(migrateLegacyHeaderPreferencesV1(preferences));
|
||||
if (preferences.version === 2) return includeRequiredHeaderItems(migrateLegacyHeaderPreferencesV2(preferences));
|
||||
return includeRequiredHeaderItems(preferences);
|
||||
});
|
||||
|
||||
export const defaultHeaderPreferences = {
|
||||
@@ -184,7 +207,11 @@ export const defaultHeaderPreferences = {
|
||||
zones: {
|
||||
left: [createBuiltinHeaderItem("logo")],
|
||||
center: [createBuiltinHeaderItem("search")],
|
||||
right: [createBuiltinHeaderItem("user")],
|
||||
right: [
|
||||
createBuiltinHeaderItem("user"),
|
||||
createBuiltinHeaderItem("boardEdit"),
|
||||
createBuiltinHeaderItem("boardSettings"),
|
||||
],
|
||||
},
|
||||
} satisfies HeaderPreferences;
|
||||
|
||||
@@ -201,4 +228,5 @@ export const parseHeaderPreferences = (value: unknown): HeaderPreferences => {
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeHeaderPreferences = (value: HeaderPreferences) => JSON.stringify(value);
|
||||
export const serializeHeaderPreferences = (value: HeaderPreferences) =>
|
||||
JSON.stringify(includeRequiredHeaderItems(value));
|
||||
@@ -25,7 +25,9 @@ export {
|
||||
headerPreferencesVersion,
|
||||
headerSearchDisplayValues,
|
||||
headerZoneIds,
|
||||
isRequiredHeaderItem,
|
||||
parseHeaderPreferences,
|
||||
requiredHeaderBuiltinItemIds,
|
||||
} from "./header-preferences";
|
||||
export type {
|
||||
HeaderBoardItem,
|
||||
|
||||
Reference in new issue
Block a user