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 });
|
||||
|
||||
Reference in new issue
Block a user