diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index b0ed1ea99..6f37d0ef9 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -18,6 +18,7 @@ "@dnd-kit/modifiers": "catalog:", "@dnd-kit/sortable": "catalog:", "@dnd-kit/utilities": "catalog:", + "@gfazioli/mantine-onboarding-tour": "catalog:", "@homarr/analytics": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx b/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx index 508074ead..493d368f1 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx @@ -2,7 +2,7 @@ import type { PropsWithChildren } from "react"; import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core"; -import { DirectionProvider, MantineProvider } from "@mantine/core"; +import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core"; import dayjs from "dayjs"; import { clientApi } from "@homarr/api/client"; @@ -18,7 +18,12 @@ export const CustomMantineProvider = ({ const manager = useColorSchemeManager(); return ( - + {children} diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 0e36cb4a1..7c7ca0766 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -17,10 +17,13 @@ import { import superjson from "superjson"; import type { SuperJSONResult } from "superjson"; +import { TRPCClientError } from "@trpc/client"; + import type { AppRouter } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { env } from "@homarr/common/env"; +import { showWarningNotification } from "@homarr/notifications"; const getWebSocketProtocol = () => { // window is not defined on server side @@ -56,6 +59,20 @@ export function TRPCReactProvider(props: PropsWithChildren) { queries: { staleTime: 5 * 1000, }, + mutations: { + onError(error) { + if ( + error instanceof TRPCClientError && + error.data?.code === "FORBIDDEN" && + error.message === "Mutations are disabled in demo mode" + ) { + showWarningNotification({ + title: "Demo mode", + message: "This action is disabled in demo mode.", + }); + } + }, + }, }, }), ); diff --git a/apps/nextjs/src/app/[locale]/auth/login/page.tsx b/apps/nextjs/src/app/[locale]/auth/login/page.tsx index 6cf00b6b3..30eb88ba0 100644 --- a/apps/nextjs/src/app/[locale]/auth/login/page.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/page.tsx @@ -1,11 +1,14 @@ import { redirect } from "next/navigation"; -import { Card, Center, Stack, Text, Title } from "@mantine/core"; +import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core"; +import { IconLogin } from "@tabler/icons-react"; import { env } from "@homarr/auth/env"; import { auth } from "@homarr/auth/next"; import { getScopedI18n } from "@homarr/translation/server"; import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url"; +import { env as appEnv } from "~/env"; + import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; import { LoginForm } from "./_login-form"; @@ -37,6 +40,13 @@ export default async function Login(props: LoginProps) { {t("subtitle")} + {appEnv.DEMO_MODE && ( + } color="blue" variant="light" w={64 * 6} maw="90vw"> + + Demo mode is enabled. Sign in with username demo and password demo + + + )} >( layout: createBoardLayout({ headerActions: , getInitialBoardAsync: getInitialBoard, + withTour: true, }), // eslint-disable-next-line no-restricted-syntax page: async ({ params }: { params: Promise }) => { diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 3b6ea7a37..cc3868e50 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { Group, Menu, ScrollArea } from "@mantine/core"; +import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; +import { Box, Group, Menu, ScrollArea } from "@mantine/core"; import { useHotkeys } from "@mantine/hooks"; import { IconBox, @@ -11,6 +12,7 @@ import { IconLayoutBoard, IconPencil, IconPencilOff, + IconPlug, IconPlus, IconReplace, IconResize, @@ -36,14 +38,16 @@ import { useBoardPermissions } from "~/components/board/permissions/client"; import { useCategoryActions } from "~/components/board/sections/category/category-actions"; import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; +import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; import { HeaderButton } from "~/components/layout/header/button"; export const BoardContentHeaderActions = () => { const [isEditMode] = useEditMode(); const board = useRequiredBoard(); const { hasChangeAccess } = useBoardPermissions(board); + const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery(); - if (!hasChangeAccess) { + if (!hasChangeAccess || isLoading) { return ; } @@ -51,11 +55,15 @@ export const BoardContentHeaderActions = () => { <> {isEditMode && } - + - - - + {!demoMode && ( + + + + + + )} @@ -67,6 +75,7 @@ const AddMenu = () => { const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); + const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal); const { addCategoryToEnd } = useCategoryActions(); const { addDynamicSection } = useDynamicSectionActions(); const { createItem } = useItemActions(); @@ -108,6 +117,10 @@ const AddMenu = () => { }); }, [openAppSelectModal, createItem]); + const handleAddIntegration = useCallback(() => { + openIntegrationSelectModal({}); + }, [openIntegrationSelectModal]); + return ( @@ -127,6 +140,10 @@ const AddMenu = () => { {t("app.action.add")} + } onClick={handleAddIntegration}> + {t("integration.action.create")} + + } onClick={handleAddCategory}> @@ -141,7 +158,7 @@ const AddMenu = () => { ); }; -const EditModeMenu = () => { +const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => { const [isEditMode, { open, close }] = useEditMode(); const board = useRequiredBoard(); const utils = clientApi.useUtils(); @@ -164,18 +181,28 @@ const EditModeMenu = () => { }, }); + const discardDemoChanges = useCallback(() => { + void utils.board.getBoardByName.invalidate({ name: board.name }); + close(); + }, [utils, board.name, close]); + const toggle = useCallback(() => { - if (isEditMode) return saveBoard(board); + if (isEditMode) { + if (demoMode) return discardDemoChanges(); + return saveBoard(board); + } open(); - }, [board, isEditMode, saveBoard, open]); + }, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]); useHotkeys([[hotkeys.toggleBoardEdit, toggle]]); usePreventLeaveWithDirty(isEditMode); return ( - - {isEditMode ? : } - + + + {isEditMode ? : } + + ); }; @@ -183,27 +210,31 @@ const SelectBoardsMenu = () => { const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); return ( - - - - - - - - - {boards.map((board) => ( - } - > - {board.name} - - ))} - - - + + + + + + + + + + + {boards.map((board) => ( + } + > + {board.name} + + ))} + + + + + ); }; @@ -244,7 +275,7 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => { }; const handleBeforeUnload = (event: BeforeUnloadEvent) => { - if (env.NODE_ENV === "development") return; // Allow to reload in development + if (env.NODE_ENV === "development") return; event.preventDefault(); event.returnValue = true; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx index 8fa031ad0..0ea5c9cf0 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx @@ -2,7 +2,7 @@ import type { PropsWithChildren } from "react"; import type { MantineColorsTuple } from "@mantine/core"; -import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem } from "@mantine/core"; +import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core"; import { useRequiredBoard } from "@homarr/boards/context"; import type { ColorScheme } from "@homarr/definitions"; @@ -33,7 +33,12 @@ export const BoardMantineProvider = ({ }); return ( - + {children} ); diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index f377c932e..3cddc6685 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -11,6 +11,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { MainHeader } from "~/components/layout/header"; import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { ClientShell } from "~/components/layout/shell"; +import { BoardTourProvider } from "~/components/onboarding/board-tour"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import type { Board } from "./_types"; import type { Params } from "./(content)/_creator"; @@ -20,14 +21,21 @@ import { BoardMantineProvider } from "./(content)/_theme"; const logger = createLogger({ module: "createBoardLayout" }); +const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => { + if (!hasSession) return <>{children}; + return {children}; +}; + interface CreateBoardLayoutProps { headerActions: JSX.Element; getInitialBoardAsync: (params: TParams) => Promise; + withTour?: boolean; } export const createBoardLayout = ({ headerActions, getInitialBoardAsync: getInitialBoard, + withTour = false, }: CreateBoardLayoutProps) => { const Layout = async ({ params, @@ -61,14 +69,16 @@ export const createBoardLayout = ({ - - } - actions={headerActions} - hasNavigation={false} - /> - {children} - + + + } + actions={headerActions} + hasNavigation={false} + /> + {children} + + diff --git a/apps/nextjs/src/app/[locale]/init/_components/init-step-card.tsx b/apps/nextjs/src/app/[locale]/init/_components/init-step-card.tsx new file mode 100644 index 000000000..e51149147 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_components/init-step-card.tsx @@ -0,0 +1,12 @@ +import type { PropsWithChildren } from "react"; +import { Card } from "@mantine/core"; + +interface InitStepCardProps { + withBorder?: boolean; +} + +export const InitStepCard = ({ children, withBorder = true }: PropsWithChildren) => ( + + {children} + +); diff --git a/apps/nextjs/src/app/[locale]/init/_constants.ts b/apps/nextjs/src/app/[locale]/init/_constants.ts new file mode 100644 index 000000000..870cbdc21 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_constants.ts @@ -0,0 +1,2 @@ +export const onboardingContentWidth = 64 * 16; +export const onboardingMaxWidth = "90vw"; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/finish/init-finish.tsx b/apps/nextjs/src/app/[locale]/init/_steps/finish/init-finish.tsx index 133581d58..ff7c2042a 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/finish/init-finish.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/finish/init-finish.tsx @@ -1,39 +1,27 @@ import type { MantineColor } from "@mantine/core"; -import { Button, Card, Stack, Text } from "@mantine/core"; -import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react"; +import { Button, Stack, Text } from "@mantine/core"; +import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react"; import { isProviderEnabled } from "@homarr/auth/server"; import { getMantineColor } from "@homarr/common"; -import { db } from "@homarr/db"; import { createDocumentationLink } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; import { Link } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui"; +import { InitStepCard } from "../../_components/init-step-card"; + export const InitFinish = async () => { - const firstBoard = await db.query.boards.findFirst({ columns: { name: true } }); const tFinish = await getScopedI18n("init.step.finish"); return ( - + {tFinish("description")} - {firstBoard ? ( - - {tFinish("action.goToBoard", { name: firstBoard.name })} - - ) : ( - - {tFinish("action.createBoard")} - - )} + + {tFinish("action.goToBoard", { name: "dashboard" })} + {isProviderEnabled("credentials") && ( { {tFinish("action.docs")} - + ); }; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/group/init-group.tsx b/apps/nextjs/src/app/[locale]/init/_steps/group/init-group.tsx index 94424ef7d..98602de73 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/group/init-group.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/group/init-group.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button, Card, Stack, TextInput } from "@mantine/core"; +import { Button, Stack, TextInput } from "@mantine/core"; import { IconArrowRight } from "@tabler/icons-react"; import type { z } from "zod/v4"; @@ -10,6 +10,8 @@ import { useZodForm } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; import { groupCreateSchema } from "@homarr/validation/group"; +import { InitStepCard } from "../../_components/init-step-card"; + export const InitGroup = () => { const t = useI18n(); const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({ @@ -34,7 +36,7 @@ export const InitGroup = () => { }; return ( - +
{
-
+ ); }; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/import/file-info-card.tsx b/apps/nextjs/src/app/[locale]/init/_steps/import/file-info-card.tsx index 3acbe646b..8a3a14fb4 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/import/file-info-card.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/import/file-info-card.tsx @@ -1,10 +1,12 @@ -import { ActionIcon, Button, Card, Group, Text } from "@mantine/core"; +import { ActionIcon, Button, Group, Text } from "@mantine/core"; import type { FileWithPath } from "@mantine/dropzone"; import { IconPencil } from "@tabler/icons-react"; import { humanFileSize } from "@homarr/common"; import { useScopedI18n } from "@homarr/translation/client"; +import { InitStepCard } from "../../_components/init-step-card"; + interface FileInfoCardProps { file: FileWithPath; onRemove: () => void; @@ -13,7 +15,7 @@ interface FileInfoCardProps { export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => { const tFileInfo = useScopedI18n("init.step.import.fileInfo"); return ( - + @@ -36,6 +38,6 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => { - + ); }; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx b/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx index cdb3faf50..c7113b66b 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx @@ -1,13 +1,14 @@ "use client"; import { startTransition, useState } from "react"; -import { Card, Stack } from "@mantine/core"; +import { Stack } from "@mantine/core"; import type { FileWithPath } from "@mantine/dropzone"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { InitialOldmarrImport } from "@homarr/old-import/components"; +import { InitStepCard } from "../../_components/init-step-card"; import { FileInfoCard } from "./file-info-card"; import { ImportDropZone } from "./import-dropzone"; @@ -20,7 +21,7 @@ export const InitImport = () => { if (!file) { return ( - + { @@ -40,12 +41,12 @@ export const InitImport = () => { }); }} /> - + ); } return ( - + setFile(null)} /> {analyseResult !== null && } diff --git a/apps/nextjs/src/app/[locale]/init/_steps/integrations/docker-discovery-panel.tsx b/apps/nextjs/src/app/[locale]/init/_steps/integrations/docker-discovery-panel.tsx new file mode 100644 index 000000000..88da9cb71 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_steps/integrations/docker-discovery-panel.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + Avatar, + Badge, + Card, + Collapse, + Group, + Loader, + Paper, + ScrollArea, + SimpleGrid, + Stack, + Text, + ThemeIcon, + UnstyledButton, +} from "@mantine/core"; +import { IconBrandDocker, IconCheck, IconChevronDown } from "@tabler/icons-react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { getIntegrationName } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; +import { IntegrationAvatar } from "@homarr/ui"; + +export interface DiscoveredIntegration { + containerId: string; + containerName: string; + kind: IntegrationKind; + suggestedUrl: string; + publishedPort: number | null; + iconUrl: string | null; +} + +export interface DiscoveredApp { + containerId: string; + containerName: string; + suggestedUrl: string; + publishedPort: number | null; + iconUrl: string | null; +} + +interface DockerDiscoveryIndicatorProps { + integrations: DiscoveredIntegration[]; + apps: DiscoveredApp[]; + selectedAppIds: Set; + onToggleApp: (containerId: string) => void; + isLoading: boolean; +} + +const APP_CARD_HEIGHT = 80; + +export const DockerDiscoveryIndicator = ({ + integrations, + apps, + selectedAppIds, + onToggleApp, + isLoading, +}: DockerDiscoveryIndicatorProps) => { + const hasApps = apps.length > 0; + const [expanded, setExpanded] = useState(false); + + const expandedOnce = useRef(false); + useEffect(() => { + if ((hasApps || integrations.length > 0) && !expandedOnce.current) { + expandedOnce.current = true; + setExpanded(true); + } + }, [hasApps, integrations.length]); + + const t = useScopedI18n("init.step.integrations.docker"); + const totalCount = integrations.length + apps.length; + + if (!isLoading && totalCount === 0) return null; + + const selectedAppCount = apps.filter((app) => selectedAppIds.has(app.containerId)).length; + + if (isLoading) { + return ( + + + + + + + + {t("scanning")} + + + {t("scanningDescription")} + + + + + + ); + } + + return ( + + setExpanded((prev) => !prev)} w="100%"> + + + + + + + + {t("detected", { count: String(totalCount) })} + + + {t("detectedBreakdown", { + integrations: String(integrations.length), + apps: String(apps.length), + })} + + + + + + + + + + {integrations.length > 0 && ( + + {integrations.map((integration) => ( + + + + {integration.containerName} + + + {getIntegrationName(integration.kind)} + + + ))} + + )} + + {hasApps && ( + + + {t("appSectionLabel", { count: String(selectedAppCount) })} + + + + {apps.map((app) => { + const isSelected = selectedAppIds.has(app.containerId); + return ( + onToggleApp(app.containerId)} + > + + + + {app.containerName} + + {isSelected && } + + + {app.containerName.at(0)?.toUpperCase()} + + + + ); + })} + + + + )} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx b/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx new file mode 100644 index 000000000..71f9345f3 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx @@ -0,0 +1,483 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + ActionIcon, + Alert, + Avatar, + Button, + Card, + Center, + Collapse, + Group, + Loader, + Paper, + Progress, + SegmentedControl, + SimpleGrid, + Stack, + Text, + TextInput, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { + IconAlertTriangle, + IconArrowRight, + IconBrandDocker, + IconCheck, + IconChevronDown, + IconSettings, +} from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import type { IntegrationKind, UrlTemplateMode } from "@homarr/definitions"; +import { buildAppUrl, buildIntegrationUrl, getIntegrationName } from "@homarr/definitions"; +import { showErrorNotification } from "@homarr/notifications"; +import { useScopedI18n } from "@homarr/translation/client"; +import { IntegrationAvatar } from "@homarr/ui"; + +import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form"; +import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid"; +import { InitStepCard } from "../../_components/init-step-card"; + +type Phase = "select" | "configure" | "done"; + +interface IntegrationDraft { + kind: IntegrationKind; + initialUrl: string; + initialName?: string; + dockerPort?: number | null; + source: "manual" | "docker"; +} + +export const InitIntegrations = () => { + const [phase, setPhase] = useState("select"); + const [drafts, setDrafts] = useState>(new Map()); + const [selectedKinds, setSelectedKinds] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [baseHost, setBaseHost] = useState(""); + const [urlMode, setUrlMode] = useState("subdomain"); + const t = useScopedI18n("init.step.integrations"); + const router = useRouter(); + + const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation(); + const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation(); + + const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery( + undefined, + { + retry: false, + refetchOnWindowFocus: false, + }, + ); + + const dockerIntegrations = dockerData?.status === "success" ? dockerData.integrations : []; + const dockerApps = dockerData?.status === "success" ? dockerData.apps : []; + const totalDetected = dockerIntegrations.length + dockerApps.length; + + const detectedKinds = useMemo(() => new Set(dockerIntegrations.map((i) => i.kind)), [dockerIntegrations]); + + const [selectedAppIds, setSelectedAppIds] = useState>(new Set()); + const [appsExpanded, setAppsExpanded] = useState(false); + + const dockerApplied = useRef(false); + useEffect(() => { + if (dockerData?.status !== "success" || dockerApplied.current) return; + dockerApplied.current = true; + + const newDrafts = new Map(drafts); + const newKinds = new Set(selectedKinds); + + for (const item of dockerData.integrations) { + if (!newDrafts.has(item.kind)) { + newDrafts.set(item.kind, { + kind: item.kind, + initialUrl: item.suggestedUrl, + initialName: item.containerName, + dockerPort: item.publishedPort, + source: "docker", + }); + } + newKinds.add(item.kind); + } + + setDrafts(newDrafts); + setSelectedKinds(Array.from(newKinds)); + setSelectedAppIds(new Set(dockerData.apps.map((app) => app.containerId))); + if (dockerData.apps.length > 0) { + setAppsExpanded(true); + } + }, [dockerData]); + + const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => { + setSelectedKinds(kinds); + }, []); + + const handleToggleApp = useCallback((containerId: string) => { + setSelectedAppIds((prev) => { + const next = new Set(prev); + if (next.has(containerId)) { + next.delete(containerId); + } else { + next.add(containerId); + } + return next; + }); + }, []); + + const handleContinueToConfig = () => { + const newDrafts = new Map(drafts); + for (const kind of selectedKinds) { + const existing = newDrafts.get(kind); + if (baseHost) { + newDrafts.set(kind, { + kind, + initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined), + initialName: existing?.initialName, + dockerPort: existing?.dockerPort, + source: existing?.source ?? "manual", + }); + } else if (!existing) { + newDrafts.set(kind, { kind, initialUrl: "", source: "manual" }); + } + } + setDrafts(newDrafts); + setCurrentIndex(0); + setPhase("configure"); + }; + + const finishSetupAsync = async () => { + setPhase("done"); + try { + const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId)); + if (appsToCreate.length > 0) { + await createApps( + appsToCreate.map((app) => { + const href = baseHost + ? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined) + : app.suggestedUrl; + return { + name: app.containerName, + href: href || null, + iconUrl: app.iconUrl, + }; + }), + ); + } + await setupIntegrations(); + router.refresh(); + } catch { + showErrorNotification({ + title: t("configure.error.title"), + message: t("configure.error.message"), + }); + setPhase("select"); + } + }; + + const advanceOrFinish = () => { + if (currentIndex + 1 >= selectedKinds.length) { + void finishSetupAsync(); + } else { + setCurrentIndex((i) => i + 1); + } + }; + + const backToSelect = () => { + setPhase("select"); + setCurrentIndex(0); + }; + + if (phase === "done") { + return ( + +
+ + + {t("configure.finishing")} + +
+
+ ); + } + + if (phase === "configure") { + return ( + + ); + } + + const selectedAppCount = dockerApps.filter((app) => selectedAppIds.has(app.containerId)).length; + + return ( + + + {t("description")} + + } title={t("urlTemplate.label")}> + + + {t("urlTemplate.warning")} + + + setBaseHost(e.currentTarget.value)} + data-autofocus + /> + setUrlMode(value as UrlTemplateMode)} + data={[ + { label: t("urlTemplate.mode.subdomain"), value: "subdomain" }, + { label: t("urlTemplate.mode.hostPort"), value: "hostPort" }, + ]} + style={{ flex: "0 0 auto" }} + /> + + {baseHost && ( + + {t("urlTemplate.preview", { example: buildIntegrationUrl("sonarr", baseHost, urlMode) })} + + )} + + + + {isDockerLoading && ( + + + + + + + {t("docker.scanning")} + + + + + )} + + {!isDockerLoading && totalDetected > 0 && ( + + + + + + + {t("docker.summary", { + apps: String(totalDetected), + integrations: String(dockerIntegrations.length), + })} + + + + )} + + + + {dockerApps.length > 0 && ( + + + setAppsExpanded((prev) => !prev)} + > + + {t("docker.appSectionLabel", { count: String(selectedAppCount) })} + + + + + + {dockerApps.map((app) => { + const isSelected = selectedAppIds.has(app.containerId); + return ( + handleToggleApp(app.containerId)} + > + + + + {app.containerName} + + {isSelected && } + + + {app.containerName.at(0)?.toUpperCase()} + + + + ); + })} + + + + + )} + + + + + + + + ); +}; + +interface ConfigurePhaseProps { + selectedKinds: IntegrationKind[]; + currentIndex: number; + drafts: Map; + baseHost: string; + setBaseHost: (value: string) => void; + urlMode: UrlTemplateMode; + setUrlMode: (value: UrlTemplateMode) => void; + onSuccess: () => void; + onCancel: () => void; + onSkip: () => void; +} + +const ConfigurePhase = ({ + selectedKinds, + currentIndex, + drafts, + baseHost, + setBaseHost, + urlMode, + setUrlMode, + onSuccess, + onCancel, + onSkip, +}: ConfigurePhaseProps) => { + const t = useScopedI18n("init.step.integrations"); + const [helpersOpen, { toggle: toggleHelpers }] = useDisclosure(false); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds + const currentKind = selectedKinds[currentIndex]!; + const draft = drafts.get(currentKind); + const progress = ((currentIndex + 1) / selectedKinds.length) * 100; + const computedUrl = baseHost + ? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined) + : draft?.initialUrl; + + return ( + + + + + + {getIntegrationName(currentKind)} + + + + + + + + + + + + {t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })} + + + + + + + {t("configure.helpers.description")} + + + setBaseHost(e.currentTarget.value)} + /> + setUrlMode(value as UrlTemplateMode)} + data={[ + { label: t("urlTemplate.mode.subdomain"), value: "subdomain" }, + { label: t("urlTemplate.mode.hostPort"), value: "hostPort" }, + ]} + style={{ flex: "0 0 auto" }} + /> + + {baseHost && ( + + {t("urlTemplate.preview", { + example: buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined), + })} + + )} + + + + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/settings/init-settings.tsx b/apps/nextjs/src/app/[locale]/init/_steps/settings/init-settings.tsx index cb024fc2f..5c99d651c 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/settings/init-settings.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/settings/init-settings.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core"; +import { Button, Group, Stack, Switch, Text } from "@mantine/core"; import { IconArrowRight } from "@tabler/icons-react"; import type { z } from "zod/v4"; @@ -13,6 +13,8 @@ import type { TranslationObject } from "@homarr/translation"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { settingsInitSchema } from "@homarr/validation/settings"; +import { InitStepCard } from "../../_components/init-step-card"; + export const InitSettings = () => { const tSection = useScopedI18n("management.page.settings.section"); const t = useI18n(); @@ -29,8 +31,8 @@ export const InitSettings = () => { return (
- - + + {tSection("analytics.title")} @@ -38,8 +40,8 @@ export const InitSettings = () => { - - + + {tSection("crawlingAndIndexing.title")} @@ -62,7 +64,7 @@ export const InitSettings = () => { /> - + diff --git a/apps/nextjs/src/app/[locale]/init/_steps/user/init-user.tsx b/apps/nextjs/src/app/[locale]/init/_steps/user/init-user.tsx index fa0ed777d..16760cd4c 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/user/init-user.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/user/init-user.tsx @@ -1,11 +1,10 @@ -import { Card } from "@mantine/core"; - +import { InitStepCard } from "../../_components/init-step-card"; import { InitUserForm } from "./init-user-form"; export const InitUser = () => { return ( - + - + ); }; diff --git a/apps/nextjs/src/app/[locale]/init/page.tsx b/apps/nextjs/src/app/[locale]/init/page.tsx index 5684e5709..212c74e73 100644 --- a/apps/nextjs/src/app/[locale]/init/page.tsx +++ b/apps/nextjs/src/app/[locale]/init/page.tsx @@ -9,10 +9,12 @@ import { getScopedI18n } from "@homarr/translation/server"; import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox"; import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox"; import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; +import { onboardingContentWidth, onboardingMaxWidth } from "./_constants"; import { BackToStart } from "./_steps/back"; import { InitFinish } from "./_steps/finish/init-finish"; import { InitGroup } from "./_steps/group/init-group"; import { InitImport } from "./_steps/import/init-import"; +import { InitIntegrations } from "./_steps/integrations/init-integrations"; import { InitSettings } from "./_steps/settings/init-settings"; import { InitStart } from "./_steps/start/init-start"; import { InitUser } from "./_steps/user/init-user"; @@ -23,6 +25,7 @@ const stepComponents: Record MaybePromise
- + diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index e88f1965b..479d9dcc3 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from "next"; import { Inter } from "next/font/google"; +import "@gfazioli/mantine-onboarding-tour/styles.css"; import "@homarr/notifications/styles.css"; import "@homarr/spotlight/styles.css"; import "@homarr/ui/styles.css"; @@ -25,6 +26,7 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation"; import { Analytics } from "~/components/layout/analytics"; import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation"; + import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization"; import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; diff --git a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx index 1093524e7..e01a8633e 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx @@ -11,6 +11,7 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { Link, SearchInput, TablePagination } from "@homarr/ui"; +import { TourTarget } from "~/components/layout/header/tour-target"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { NoResults } from "~/components/no-results"; @@ -45,9 +46,11 @@ export default async function AppsPage(props: AppsPageProps) { title={t("page.list.title")} primaryAction={ canCreate ? ( - <MobileAffixButton component={Link} href="/manage/apps/new"> - {t("page.create.title")} - </MobileAffixButton> + <TourTarget id="manage-apps-create"> + <MobileAffixButton component={Link} href="/manage/apps/new"> + {t("page.create.title")} + </MobileAffixButton> + </TourTarget> ) : undefined } toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />} @@ -56,11 +59,13 @@ export default async function AppsPage(props: AppsPageProps) { > {apps.length === 0 && <AppNoResults />} {apps.length > 0 && ( - <Stack gap="sm"> - {apps.map((app) => ( - <AppCard key={app.id} app={app} /> - ))} - </Stack> + <TourTarget id="manage-apps-list"> + <Stack gap="sm"> + {apps.map((app) => ( + <AppCard key={app.id} app={app} /> + ))} + </Stack> + </TourTarget> )} </ManagePageLayout> ); diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx index 45bbcd2b5..b7f994c41 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx @@ -28,6 +28,7 @@ import { getScopedI18n } from "@homarr/translation/server"; import { Link, UserAvatar } from "@homarr/ui"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; +import { TourTarget } from "~/components/layout/header/tour-target"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { NoResults } from "~/components/no-results"; import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown"; @@ -42,18 +43,26 @@ export default async function ManageBoardsPage() { return ( <ManagePageLayout title={t("title")} - primaryAction={canCreateBoards ? <CreateBoardButton /> : undefined} + primaryAction={ + canCreateBoards ? ( + <TourTarget id="manage-boards-create"> + <CreateBoardButton /> + </TourTarget> + ) : undefined + } floatingPrimaryAction={canCreateBoards} > {boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />} {boards.length > 0 && ( - <Grid> - {boards.map((board) => ( - <GridCol span={{ base: 12, md: 6 }} key={board.id}> - <BoardCard board={board} /> - </GridCol> - ))} - </Grid> + <TourTarget id="manage-boards-list"> + <Grid> + {boards.map((board) => ( + <GridCol span={{ base: 12, md: 6 }} key={board.id}> + <BoardCard board={board} /> + </GridCol> + ))} + </Grid> + </TourTarget> )} </ManagePageLayout> ); diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx deleted file mode 100644 index 25333e127..000000000 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import type { ChangeEvent } from "react"; -import React, { useMemo, useState } from "react"; -import { Flex, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core"; -import { IconSearch } from "@tabler/icons-react"; - -import { getIntegrationName, integrationKinds } from "@homarr/definitions"; -import { useI18n } from "@homarr/translation/client"; -import { IntegrationAvatar, Link } from "@homarr/ui"; - -interface IntegrationCreateDropdownContentProps { - enableMockIntegration: boolean; -} - -export const IntegrationCreateDropdownContent = ({ enableMockIntegration }: IntegrationCreateDropdownContentProps) => { - const t = useI18n(); - const [search, setSearch] = useState(""); - - const filteredKinds = useMemo(() => { - return integrationKinds - .filter((kind) => enableMockIntegration || kind !== "mock") - .filter((kind) => getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim())) - .sort((kindA, kindB) => getIntegrationName(kindA).localeCompare(getIntegrationName(kindB))); - }, [search, enableMockIntegration]); - - const handleSearch = React.useCallback( - (event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value), - [setSearch], - ); - - return ( - <Flex direction={{ base: "column-reverse", md: "column" }} gap="sm"> - <TextInput - leftSection={<IconSearch stroke={1.5} size={20} />} - placeholder={t("integration.page.list.search")} - value={search} - data-autofocus - onChange={handleSearch} - variant="filled" - /> - - {filteredKinds.length > 0 ? ( - <ScrollArea.Autosize mah={384}> - {filteredKinds.map((kind) => ( - <Menu.Item component={Link} href={`/manage/integrations/new?kind=${kind}`} key={kind}> - <Group> - <IntegrationAvatar kind={kind} size="sm" /> - <Text size="sm">{getIntegrationName(kind)}</Text> - </Group> - </Menu.Item> - ))} - </ScrollArea.Autosize> - ) : ( - <Menu.Item disabled>{t("common.noResults")}</Menu.Item> - )} - </Flex> - ); -}; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form-wrapper.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form-wrapper.tsx new file mode 100644 index 000000000..7d7768ec2 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form-wrapper.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import type { IntegrationKind } from "@homarr/definitions"; + +import { NewIntegrationForm } from "./_integration-new-form"; + +interface IntegrationNewFormWrapperProps { + kind: IntegrationKind; + initialUrl?: string; + initialName?: string; +} + +export const IntegrationNewFormWrapper = ({ kind, initialUrl, initialName }: IntegrationNewFormWrapperProps) => { + const router = useRouter(); + + return ( + <NewIntegrationForm + kind={kind} + initialUrl={initialUrl} + initialName={initialName} + onSuccess={() => router.push("/manage/integrations")} + /> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index b7fbf3291..b19669a33 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -1,9 +1,9 @@ "use client"; import { startTransition, useState } from "react"; -import { useRouter } from "next/navigation"; import { Alert, + Anchor, Button, Checkbox, Collapse, @@ -16,7 +16,7 @@ import { Text, TextInput, } from "@mantine/core"; -import { IconCheck, IconInfoCircle } from "@tabler/icons-react"; +import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react"; import { z } from "zod/v4"; import { clientApi } from "@homarr/api/client"; @@ -27,6 +27,7 @@ import type { IntegrationKind } from "@homarr/definitions"; import { getAllSecretKindOptions, getIconUrl, + getIntegrationApiKeyUrl, getIntegrationDefaultUrl, getIntegrationName, integrationDefs, @@ -45,9 +46,13 @@ import { IntegrationTestConnectionError } from "../_components/test-connection/i import type { AnyMappedTestConnectionError } from "../_components/test-connection/types"; interface NewIntegrationFormProps { - searchParams: Partial<z.infer<typeof integrationCreateSchema>> & { - kind: IntegrationKind; - }; + kind: IntegrationKind; + initialUrl?: string; + initialName?: string; + onSuccess: () => void; + onCancel?: () => void; + onSkip?: () => void; + isOnboarding?: boolean; } const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( @@ -58,39 +63,48 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( }), ); -export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => { +export const NewIntegrationForm = ({ + kind, + initialUrl, + initialName, + onSuccess, + onCancel, + onSkip, + isOnboarding = false, +}: NewIntegrationFormProps) => { const t = useI18n(); - const secretKinds = getAllSecretKindOptions(searchParams.kind); + const secretKinds = getAllSecretKindOptions(kind); const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url")); - const router = useRouter(); const { data: session } = useSession(); - const canCreateApps = session?.user.permissions.includes("app-create") ?? false; + const canCreateApps = !isOnboarding && (session?.user.permissions.includes("app-create") ?? false); - let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? ""; + let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? ""; if (hasUrlSecret) { - // Placeholder Url, replaced with origin of the secret Url on submit url = "http://localhost"; } const form = useZodForm(formSchema, { initialValues: { - name: searchParams.name ?? getIntegrationName(searchParams.kind), + name: initialName ?? getIntegrationName(kind), url, secrets: secretKinds[0].map((kind) => ({ kind, value: "", })), - attemptSearchEngineCreation: true, - hasApp: false, - appHref: url, + attemptSearchEngineCreation: !isOnboarding, + hasApp: !isOnboarding, + appHref: isOnboarding ? null : url, appId: null, }, }); - const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation({ + const { mutateAsync: createIntegrationAsync, isPending: isCreatePending } = clientApi.integration.create.useMutation({ async onSuccess() { await revalidatePathActionAsync("/manage/integrations"); }, }); + const { mutateAsync: createOnboardingIntegrationAsync, isPending: isOnboardingCreatePending } = + clientApi.onboard.createIntegration.useMutation(); + const isPending = isCreatePending || isOnboardingCreatePending; const [error, setError] = useState<null | AnyMappedTestConnectionError>(null); const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => { @@ -98,6 +112,39 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => ? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin : values.url; + const onMutationSuccess = (data: { error?: AnyMappedTestConnectionError } | undefined | void) => { + if (data && "error" in data && data.error) { + setError(data.error); + showErrorNotification({ + title: t("integration.page.create.notification.error.title"), + message: t("integration.page.create.notification.error.message"), + }); + return; + } + + showSuccessNotification({ + title: t("integration.page.create.notification.success.title"), + message: t("integration.page.create.notification.success.message"), + }); + + onSuccess(); + }; + + const onMutationError = () => { + showErrorNotification({ + title: t("integration.page.create.notification.error.title"), + message: t("integration.page.create.notification.error.message"), + }); + }; + + if (isOnboarding) { + await createOnboardingIntegrationAsync( + { kind, name: values.name, url, secrets: values.secrets }, + { onSuccess: onMutationSuccess, onError: onMutationError }, + ); + return; + } + const hasCustomHref = appHref !== null && appHref.trim().length >= 1; const app = hasApp @@ -106,49 +153,21 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => : { name: values.name, href: hasCustomHref ? appHref : url, - iconUrl: getIconUrl(searchParams.kind), + iconUrl: getIconUrl(kind), description: null, pingUrl: url, } : undefined; await createIntegrationAsync( - { - kind: searchParams.kind, - ...values, - url, - app, - }, - { - onSuccess(data) { - // We do it this way as we are unable to send a typesafe error through onError - if (data?.error) { - setError(data.error); - showErrorNotification({ - title: t("integration.page.create.notification.error.title"), - message: t("integration.page.create.notification.error.message"), - }); - return; - } - - showSuccessNotification({ - title: t("integration.page.create.notification.success.title"), - message: t("integration.page.create.notification.success.message"), - }); - - router.push("/manage/integrations"); - }, - onError: () => { - showErrorNotification({ - title: t("integration.page.create.notification.error.title"), - message: t("integration.page.create.notification.error.message"), - }); - }, - }, + { kind, ...values, url, app }, + { onSuccess: onMutationSuccess, onError: onMutationError }, ); }; - const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search"); + const integrationCategories = integrationDefs[kind].category.flat(); + const supportsSearchEngine = + integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch"); return ( <form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}> @@ -175,27 +194,39 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => <Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text> </Alert> )} + <ApiKeySettingsLink kind={kind} url={form.values.url} /> </Stack> </Fieldset> {error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />} - {supportsSearchEngine && ( + {!isOnboarding && supportsSearchEngine && ( <Checkbox label={t("integration.field.attemptSearchEngineCreation.label")} description={t("integration.field.attemptSearchEngineCreation.description", { - kind: getIntegrationName(searchParams.kind), + kind: getIntegrationName(kind), })} {...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })} /> )} - <AppForm form={form} canCreateApps={canCreateApps} /> + {!isOnboarding && <AppForm form={form} canCreateApps={canCreateApps} />} <Group justify="end" align="center"> - <Button variant="default" component={Link} href="/manage/integrations"> - {t("common.action.backToOverview")} - </Button> + {onCancel ? ( + <Button variant="default" onClick={onCancel}> + {t("common.action.backToOverview")} + </Button> + ) : ( + <Button variant="default" component={Link} href="/manage/integrations"> + {t("common.action.backToOverview")} + </Button> + )} + {onSkip && ( + <Button variant="default" onClick={onSkip}> + {t("common.action.skip")} + </Button> + )} <Button type="submit" loading={isPending}> {t("integration.testConnection.action.create")} </Button> @@ -266,6 +297,40 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c ); }; +const normalizeUrl = (raw: string): string | null => { + try { + return new URL(raw).href; + } catch { + // Bare IP / hostname — prepend http:// and retry + } + try { + return new URL(`http://${raw}`).href; + } catch { + return null; + } +}; + +const ApiKeySettingsLink = ({ kind, url }: { kind: IntegrationKind; url: string }) => { + const t = useI18n(); + const apiKeyUrl = getIntegrationApiKeyUrl(url, kind); + if (!apiKeyUrl) return null; + + const resolved = normalizeUrl(url); + if (!resolved) return null; + + const fullApiKeyUrl = getIntegrationApiKeyUrl(resolved, kind); + + return ( + <Anchor href={fullApiKeyUrl ?? apiKeyUrl} target="_blank" rel="noopener noreferrer" size="sm"> + <Group gap={4}> + <IconKey size={14} stroke={1.5} /> + <Text size="sm">{t("integration.field.apiKeySettings.label")}</Text> + <IconExternalLink size={14} stroke={1.5} /> + </Group> + </Anchor> + ); +}; + type IntegrationAppSelectProps = Modify< GetInputPropsReturnType, { diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-select-grid.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-select-grid.tsx new file mode 100644 index 000000000..cae5ee38b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-select-grid.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import type { IntegrationKind } from "@homarr/definitions"; + +import { IntegrationSelectGrid } from "~/components/integration/integration-select-grid"; + +interface IntegrationNewSelectGridProps { + enableMockIntegration: boolean; +} + +export const IntegrationNewSelectGrid = ({ enableMockIntegration }: IntegrationNewSelectGridProps) => { + const router = useRouter(); + + const handleSelect = (kind: IntegrationKind) => { + router.push(`/manage/integrations/new?kind=${kind}`); + }; + + return <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={enableMockIntegration} />; +}; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx index 1938a569e..3194134c3 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx @@ -3,21 +3,21 @@ import { Container, Group, Stack, Title } from "@mantine/core"; import { z } from "zod/v4"; import { auth } from "@homarr/auth/next"; -import type { IntegrationKind } from "@homarr/definitions"; import { getIntegrationName, integrationKinds } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; import { IntegrationAvatar } from "@homarr/ui"; -import type { integrationCreateSchema } from "@homarr/validation/integration"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; -import { NewIntegrationForm } from "./_integration-new-form"; +import { env } from "~/env"; +import { IntegrationNewFormWrapper } from "./_integration-new-form-wrapper"; +import { IntegrationNewSelectGrid } from "./_integration-new-select-grid"; interface NewIntegrationPageProps { - searchParams: Promise< - Partial<z.infer<typeof integrationCreateSchema>> & { - kind: IntegrationKind; - } - >; + searchParams: Promise<{ + kind?: string; + url?: string; + name?: string; + }>; } export default async function IntegrationsNewPage(props: NewIntegrationPageProps) { @@ -28,8 +28,20 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps } const result = z.enum(integrationKinds).safeParse(searchParams.kind); + if (!result.success) { - notFound(); + const t = await getScopedI18n("integration"); + return ( + <> + <DynamicBreadcrumb /> + <Container> + <Stack> + <Title>{t("action.create")} + + + + + ); } const tCreate = await getScopedI18n("integration.page.create"); @@ -45,7 +57,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps {tCreate("title", { name: getIntegrationName(currentKind) })} - + diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx index 994d63b3c..0eeb25c1a 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx @@ -1,5 +1,4 @@ import { Fragment } from "react"; -import type { PropsWithChildren } from "react"; import { redirect } from "next/navigation"; import { AccordionControl, @@ -8,12 +7,8 @@ import { ActionIcon, ActionIconGroup, Anchor, - Button, Divider, Group, - Menu, - MenuDropdown, - MenuTarget, Stack, Table, TableTbody, @@ -23,7 +18,7 @@ import { TableTr, Text, } from "@mantine/core"; -import { IconChevronDown, IconPencil, IconPlugX } from "@tabler/icons-react"; +import { IconPencil, IconPlugX } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; @@ -34,13 +29,12 @@ import { getIntegrationName } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui"; -import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; +import { TourTarget } from "~/components/layout/header/tour-target"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; +import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { NoResults } from "~/components/no-results"; -import { env } from "~/env"; import { ActiveTabAccordion } from "../../../../components/active-tab-accordion"; import { DeleteIntegrationActionButton } from "./_integration-buttons"; -import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown"; import classes from "./page.module.css"; interface IntegrationsPageProps { @@ -67,41 +61,22 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) { title={t("page.list.title")} primaryAction={ canCreateIntegrations ? ( - - - - - - - + + + {t("action.create")} + + ) : undefined } floatingPrimaryAction={canCreateIntegrations} > - + + + ); } -const IntegrationSelectMenu = ({ children }: PropsWithChildren) => { - return ( - - {children} - - - - - ); -}; - interface IntegrationListProps { integrations: RouterOutputs["integration"]["all"]; activeTab?: IntegrationKind; @@ -134,12 +109,12 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps return ( - {entries.map(([kind, integrations], index) => ( + {entries.map(([kind, kindIntegrations], index) => ( }> {getIntegrationName(kind)} - + @@ -152,7 +127,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps - {integrations.map((integration) => ( + {kindIntegrations.map((integration) => ( {integration.name} @@ -173,7 +148,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps > - + )} @@ -184,9 +159,9 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps - {integrations.map((integration, index) => ( + {kindIntegrations.map((integration, integrationIndex) => ( - {index !== 0 && } + {integrationIndex !== 0 && } {integration.name} @@ -201,7 +176,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps > - + )} diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index 32013221c..368d0b2f7 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -36,6 +36,7 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; import type { NavigationLink } from "~/components/layout/navigation"; import { MainNavigation } from "~/components/layout/navigation"; import { ClientShell } from "~/components/layout/shell"; +import { ManageTourProvider } from "~/components/onboarding/manage-tour"; export default async function ManageLayout({ children }: PropsWithChildren) { const t = await getScopedI18n("management.navbar"); @@ -45,11 +46,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) { label: t("items.home"), icon: IconHomeFilled, href: "/manage", + "data-onboarding-tour-id": "manage-welcome", }, { icon: IconLayoutDashboardFilled, href: "/manage/boards", label: t("items.boards"), + "data-onboarding-tour-id": "manage-boards", }, { icon: IconBox, @@ -59,12 +62,14 @@ export default async function ManageLayout({ children }: PropsWithChildren) { iconProps: { strokeWidth: 2.5, }, + "data-onboarding-tour-id": "manage-apps", }, { icon: IconAffiliateFilled, href: "/manage/integrations", label: t("items.integrations"), hidden: !session, + "data-onboarding-tour-id": "manage-integrations", }, { icon: IconSearch, @@ -74,17 +79,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) { iconProps: { strokeWidth: 2.5, }, + "data-onboarding-tour-id": "manage-search-engines", }, { icon: IconPhotoFilled, href: "/manage/medias", label: t("items.medias"), hidden: !session, + "data-onboarding-tour-id": "manage-medias", }, { icon: IconUserFilled, label: t("items.users.label"), hidden: !session?.user.permissions.includes("admin"), + "data-onboarding-tour-id": "manage-users", items: [ { label: t("items.users.items.manage"), @@ -153,6 +161,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) { href: "/manage/settings", icon: IconSettingsFilled, hidden: !session?.user.permissions.includes("admin"), + "data-onboarding-tour-id": "manage-settings", }, { label: t("items.help.label"), @@ -191,11 +200,17 @@ export default async function ManageLayout({ children }: PropsWithChildren) { }, ]; - return ( + const isAdmin = session?.user.permissions.includes("admin") ?? false; + + const shell = ( {children} ); + + if (!session) return shell; + + return {shell}; } diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx index 4820a3a54..d2c1def97 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx @@ -1,23 +1,26 @@ "use client"; +import { useRouter } from "next/navigation"; import type { MantineColor } from "@mantine/core"; -import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core"; +import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core"; import { IconCategoryPlus, + IconDots, + IconFileText, IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise, IconTrash, } from "@tabler/icons-react"; -import type { MRT_ColumnDef } from "mantine-react-table"; +import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { useTimeAgo } from "@homarr/common"; +import { humanFileSize, useTimeAgo } from "@homarr/common"; import type { ContainerState } from "@homarr/docker"; -import { containerStateColorMap } from "@homarr/docker/shared"; +import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared"; import { useModalAction } from "@homarr/modals"; import { AddDockerAppToHomarr } from "@homarr/modals-collection"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; @@ -27,25 +30,30 @@ import type { TablerIcon } from "@homarr/ui"; import { OverflowBadge } from "@homarr/ui"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; -const createColumns = ( - t: TranslationFunction, -): MRT_ColumnDef[] => [ +type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number]; + +const createContainerLogsPath = (container: Pick) => + `/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`; + +const createColumns = (t: TranslationFunction): MRT_ColumnDef[] => [ { accessorKey: "name", header: t("docker.field.name.label"), Cell({ renderedCellValue, row }) { return ( - + {row.original.name.at(0)?.toUpperCase()} - {renderedCellValue} + + {renderedCellValue} + ); }, @@ -76,11 +84,11 @@ const createColumns = ( maxSize: 200, Cell({ renderedCellValue, cell }) { return ( - - - {renderedCellValue} - - + + + {renderedCellValue} + + ); }, }, @@ -95,18 +103,79 @@ const createColumns = ( ); }, }, + { + id: "cpuUsage", + accessorKey: "cpuUsage", + size: 120, + header: t("docker.field.stats.cpu.label"), + enableHiding: true, + sortingFn: (rowA, rowB) => { + const cpuUsageA = safeValue(rowA.original.cpuUsage); + const cpuUsageB = safeValue(rowB.original.cpuUsage); + + return cpuUsageA - cpuUsageB; + }, + Cell({ row }) { + const cpuUsage = safeValue(row.original.cpuUsage); + + return ( + + {cpuUsage.toFixed(2)}% + + ); + }, + }, + { + id: "memoryUsage", + accessorKey: "memoryUsage", + size: 140, + header: t("docker.field.stats.memory.label"), + enableHiding: true, + sortingFn: (rowA, rowB) => { + const memoryUsageA = safeValue(rowA.original.memoryUsage); + const memoryUsageB = safeValue(rowB.original.memoryUsage); + + return memoryUsageA - memoryUsageB; + }, + Cell({ row }) { + const bytesUsage = safeValue(row.original.memoryUsage); + + return ( + + {humanFileSize(bytesUsage)} + + ); + }, + }, ]; -export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) { +export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) { const t = useI18n(); const tDocker = useScopedI18n("docker"); const { data } = clientApi.docker.getContainers.useQuery(undefined, { - initialData, + initialData: { containers, timestamp }, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, }); const relativeTime = useTimeAgo(data.timestamp); + const utils = clientApi.useUtils(); + const { mutate, isPending } = clientApi.docker.invalidate.useMutation({ + async onSuccess() { + await utils.docker.getContainers.invalidate(); + showSuccessNotification({ + title: tDocker("action.refresh.notification.success.title"), + message: tDocker("action.refresh.notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + title: tDocker("action.refresh.notification.error.title"), + message: tDocker("action.refresh.notification.error.message"), + }); + }, + }); + const table = useTranslatedMantineReactTable({ data: data.containers, enableDensityToggle: false, @@ -114,6 +183,8 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers" enableColumnFilters: false, enablePagination: false, enableRowSelection: true, + enableRowActions: true, + positionActionsColumn: "last", positionToolbarAlertBanner: "top", enableTableFooter: false, enableBottomToolbar: false, @@ -124,36 +195,15 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers" autoFocus: true, }, - initialState: { density: "xs", showGlobalFilter: true }, - renderTopToolbarCustomActions: () => { - const utils = clientApi.useUtils(); - const { mutate, isPending } = clientApi.docker.invalidate.useMutation({ - async onSuccess() { - await utils.docker.getContainers.invalidate(); - showSuccessNotification({ - title: tDocker("action.refresh.notification.success.title"), - message: tDocker("action.refresh.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - title: tDocker("action.refresh.notification.error.title"), - message: tDocker("action.refresh.notification.error.message"), - }); - }, - }); - - return ( - - ); - }, + initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } }, + mantineTableBodyCellProps: { style: { padding: "4px 8px" } }, + mantineTableHeadCellProps: { style: { padding: "4px 8px" } }, + renderRowActions: ({ row }: { row: MRT_Row }) => , + renderTopToolbarCustomActions: () => ( + + ), renderToolbarAlertBannerContent: ({ groupedAlert, table }) => { const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original); return ( @@ -181,28 +231,104 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers" ); } +const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon: TablerIcon; color: MantineColor }[] = [ + { action: "start", icon: IconPlayerPlay, color: "green" }, + { action: "stop", icon: IconPlayerStop, color: "red" }, + { action: "restart", icon: IconRotateClockwise, color: "orange" }, + { action: "remove", icon: IconTrash, color: "red" }, +]; + +const ContainerRowMenu = ({ container }: { container: DockerContainer }) => { + const t = useScopedI18n("docker.action"); + const router = useRouter(); + const utils = clientApi.useUtils(); + const { openModal } = useModalAction(AddDockerAppToHomarr); + + const useContainerAction = (action: "start" | "stop" | "restart" | "remove") => + clientApi.docker[`${action}All`].useMutation({ + async onSettled() { + await utils.docker.getContainers.invalidate(); + }, + onSuccess() { + showSuccessNotification({ + title: t(`${action}.notification.success.title`), + message: t(`${action}.notification.success.message`), + }); + }, + onError() { + showErrorNotification({ + title: t(`${action}.notification.error.title`), + message: t(`${action}.notification.error.message`), + }); + }, + }); + + const mutations = { + start: useContainerAction("start"), + stop: useContainerAction("stop"), + restart: useContainerAction("restart"), + remove: useContainerAction("remove"), + }; + + return ( + + + e.stopPropagation()}> + + + + + } + onClick={() => router.push(createContainerLogsPath(container))} + > + {t("logs.label")} + + + {containerActions.map(({ action, icon: Icon, color }) => ( + } + color={color} + onClick={() => mutations[action].mutate({ ids: [container.id] })} + > + {t(`${action}.label`)} + + ))} + + } + color="red" + onClick={() => openModal({ selectedContainers: [container] })} + > + {t("addToHomarr.label")} + + + + ); +}; + interface ContainerActionBarProps { - selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"]; + selectedContainers: DockerContainer[]; } const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => { const t = useScopedI18n("docker.action"); const { openModal } = useModalAction(AddDockerAppToHomarr); - const handleClick = () => { - openModal({ - selectedContainers, - }); - }; const selectedIds = selectedContainers.map((container) => container.id); return ( - - - - - diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/client.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/client.tsx new file mode 100644 index 000000000..4bc5de800 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/client.tsx @@ -0,0 +1,10 @@ +"use client"; + +import dynamic from "next/dynamic"; + +export const ClientSideDockerLogsTerminal = dynamic( + () => import("./terminal").then(({ DockerLogsTerminal }) => DockerLogsTerminal), + { + ssr: false, + }, +); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/page.tsx new file mode 100644 index 000000000..2c7a84521 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/page.tsx @@ -0,0 +1,47 @@ +import { notFound } from "next/navigation"; +import { Box, Group } from "@mantine/core"; + +import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/docker/env"; + +import "@xterm/xterm/css/xterm.css"; + +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { fullHeightWithoutHeaderAndFooter } from "~/constants"; +import { ClientSideDockerLogsTerminal } from "./client"; + +interface DockerContainerLogsPageProps { + params: Promise<{ containerId: string }>; + searchParams: Promise<{ name?: string }>; +} + +export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) { + const session = await auth(); + if (!(session?.user.permissions.includes("admin") && env.ENABLE_DOCKER)) { + notFound(); + } + + const { containerId } = await params; + const { name } = await searchParams; + + try { + await api.docker.logs({ id: containerId, tail: 1 }); + } catch { + notFound(); + } + + return ( + <> + + + + + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.module.css b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.module.css new file mode 100644 index 000000000..c654cf6de --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.module.css @@ -0,0 +1,3 @@ +.outerTerminal > div { + height: 100%; +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.tsx new file mode 100644 index 000000000..a8d0e1fa8 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { Box } from "@mantine/core"; +import { CanvasAddon } from "@xterm/addon-canvas"; +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; + +import { clientApi } from "@homarr/api/client"; + +import classes from "./terminal.module.css"; + +interface DockerLogsTerminalProps { + containerId: string; +} + +export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => { + const ref = useRef(null); + const terminalRef = useRef(null); + const pendingLogsRef = useRef([]); + const isTerminalReadyRef = useRef(false); + + const writeToTerminal = useCallback((data: string) => { + if (!isTerminalReadyRef.current || !terminalRef.current) { + pendingLogsRef.current.push(data); + return; + } + + terminalRef.current.write(data); + terminalRef.current.refresh(0, terminalRef.current.rows - 1); + }, []); + + clientApi.docker.subscribeLogs.useSubscription( + { id: containerId, tail: 200 }, + { + onData: writeToTerminal, + onError(err) { + writeToTerminal(`\r\n\x1b[31m${err.message}\x1b[0m`); + }, + }, + ); + + useEffect(() => { + if (!ref.current) { + return () => undefined; + } + + const canvasAddon = new CanvasAddon(); + + terminalRef.current = new Terminal({ + cursorBlink: false, + disableStdin: true, + convertEol: true, + fontSize: 14, + }); + terminalRef.current.open(ref.current); + terminalRef.current.loadAddon(canvasAddon); + + isTerminalReadyRef.current = true; + const terminal = terminalRef.current; + pendingLogsRef.current.forEach((data) => terminal.write(data)); + pendingLogsRef.current = []; + terminal.refresh(0, terminal.rows - 1); + + setTimeout(() => { + const fitAddon = new FitAddon(); + terminalRef.current?.loadAddon(fitAddon); + fitAddon.fit(); + }); + + return () => { + isTerminalReadyRef.current = false; + pendingLogsRef.current = []; + canvasAddon.dispose(); + terminalRef.current?.dispose(); + terminalRef.current = null; + }; + }, [writeToTerminal]); + + return ; +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/font-size-slider.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/font-size-slider.tsx new file mode 100644 index 000000000..d76c3df9d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/font-size-slider.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Group, Slider, Text } from "@mantine/core"; +import { IconTextSize } from "@tabler/icons-react"; + +import { useLogContext } from "./log-context"; + +export const LogFontSizeSlider = () => { + const { fontSize, setFontSize } = useLogContext(); + + return ( + + + `${val}px`} + style={{ flex: 1 }} + size="xs" + /> + + {fontSize}px + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/log-context.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/log-context.tsx index 9a58ffaaa..2caa5da38 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/log-context.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/log-context.tsx @@ -6,10 +6,23 @@ import { createContext, useContext, useMemo, useState } from "react"; import type { LogLevel } from "@homarr/core/infrastructure/logs/constants"; import { logLevels } from "@homarr/core/infrastructure/logs/constants"; +const FONT_SIZE_KEY = "homarr-log-font-size"; +const DEFAULT_FONT_SIZE = 14; + +const readStoredFontSize = () => { + if (typeof window === "undefined") return DEFAULT_FONT_SIZE; + const stored = localStorage.getItem(FONT_SIZE_KEY); + if (!stored) return DEFAULT_FONT_SIZE; + const parsed = Number(stored); + return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 10), 24) : DEFAULT_FONT_SIZE; +}; + const LogContext = createContext<{ level: LogLevel; setLevel: (level: LogLevel) => void; activeLevels: LogLevel[]; + fontSize: number; + setFontSize: (size: number) => void; } | null>(null); interface LogContextProviderProps extends PropsWithChildren { @@ -18,9 +31,19 @@ interface LogContextProviderProps extends PropsWithChildren { export const LogContextProvider = ({ defaultLevel, children }: LogContextProviderProps) => { const [level, setLevel] = useState(defaultLevel); + const [fontSize, setFontSizeRaw] = useState(readStoredFontSize); const activeLevels = useMemo(() => logLevels.slice(0, logLevels.indexOf(level) + 1), [level]); - return {children}; + const setFontSize = (size: number) => { + setFontSizeRaw(size); + localStorage.setItem(FONT_SIZE_KEY, String(size)); + }; + + return ( + + {children} + + ); }; export const useLogContext = () => { diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx index b6901934c..a981f2f57 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx @@ -13,6 +13,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { fullHeightWithoutHeaderAndFooter } from "~/constants"; import { createMetaTitle } from "~/metadata"; import { ClientSideTerminalComponent } from "./client"; +import { LogFontSizeSlider } from "./font-size-slider"; import { LogLevelSelection } from "./level-selection"; import { LogContextProvider } from "./log-context"; @@ -38,7 +39,10 @@ export default async function LogsManagementPage() { - + + + + diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx index 9e8c569af..20f50ffd9 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx @@ -13,9 +13,11 @@ import classes from "./terminal.module.css"; export const TerminalComponent = () => { const ref = useRef(null); - const { activeLevels } = useLogContext(); + const { activeLevels, fontSize } = useLogContext(); const terminalRef = useRef(null); + const fitAddonRef = useRef(null); + clientApi.log.subscribe.useSubscription( { levels: activeLevels, @@ -26,7 +28,6 @@ export const TerminalComponent = () => { terminalRef.current?.refresh(0, terminalRef.current.rows - 1); }, onError(err) { - // This makes sense as logging might cause an infinite loop alert(err); }, }, @@ -43,22 +44,30 @@ export const TerminalComponent = () => { cursorBlink: false, disableStdin: true, convertEol: true, + fontSize, }); terminalRef.current.open(ref.current); terminalRef.current.loadAddon(canvasAddon); - // This is a hack to make sure the terminal is rendered before we try to fit it - // You can blame @Meierschlumpf for this setTimeout(() => { const fitAddon = new FitAddon(); + fitAddonRef.current = fitAddon; terminalRef.current?.loadAddon(fitAddon); fitAddon.fit(); }); return () => { - terminalRef.current?.dispose(); canvasAddon.dispose(); + terminalRef.current?.dispose(); + terminalRef.current = null; }; }, []); + + useEffect(() => { + if (!terminalRef.current) return; + terminalRef.current.options.fontSize = fontSize; + fitAddonRef.current?.fit(); + }, [fontSize]); + return ; }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx new file mode 100644 index 000000000..76f33a8db --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Badge, Button, Group, Stack, Text } from "@mantine/core"; +import { IconRefresh } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { showSuccessNotification } from "@homarr/notifications"; +import { useScopedI18n } from "@homarr/translation/client"; + +export const ResetTours = () => { + const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours"); + const utils = clientApi.useUtils(); + const { data: tourStatus } = clientApi.user.getTourStatus.useQuery(); + const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({ + onSuccess() { + showSuccessNotification({ + title: t("resetSuccess.title"), + message: t("resetSuccess.message"), + }); + void utils.user.getTourStatus.invalidate(); + }, + }); + + const tours = [ + { key: "management" as const, completed: tourStatus?.completedManageTour ?? false }, + { key: "dashboard" as const, completed: tourStatus?.completedBoardTour ?? false }, + ]; + + return ( + + + {tours.map(({ key, completed }) => ( + + {t(`name.${key}`)}: {completed ? t("status.completed") : t("status.notStarted")} + + ))} + + + {t("description")} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx index cf68c20e2..cc20a3459 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx @@ -13,6 +13,7 @@ import { canAccessUserEditPage } from "../access"; import { DeleteUserButton } from "./_components/_delete-user-button"; import { UserGeneralSettingsForm } from "./_components/_general-settings-form"; import { UserProfileAvatarForm } from "./_components/_profile-avatar-form"; +import { ResetTours } from "./_components/_reset-tours"; interface Props { params: Promise<{ @@ -87,6 +88,13 @@ export default async function EditUserPage(props: Props) { + {session?.user.id === user.id && ( + + {tGeneral("item.onboardingTours.title")} + + + )} + ) { const session = await auth(); const t = await getI18n(); const tUser = await getScopedI18n("management.page.user"); - const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound); + const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound).catch(catchTrpcUnauthorized); if (!canAccessUserEditPage(session, user.id)) { notFound(); diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/page.tsx index f3098258b..cbd83c047 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/page.tsx @@ -5,7 +5,7 @@ import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import { getScopedI18n } from "@homarr/translation/server"; -import { catchTrpcNotFound } from "~/errors/trpc-catch-error"; +import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error"; import { canAccessUserEditPage } from "../access"; import { ChangePasswordForm } from "./_components/_change-password-form"; @@ -23,7 +23,8 @@ export default async function UserSecurityPage(props: Props) { .getById({ userId: params.userId, }) - .catch(catchTrpcNotFound); + .catch(catchTrpcNotFound) + .catch(catchTrpcUnauthorized); if (!canAccessUserEditPage(session, user.id)) { notFound(); diff --git a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx index f443ae585..3997bda60 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx @@ -1,20 +1,7 @@ "use client"; import { startTransition, useCallback, useState } from "react"; -import { - Badge, - Button, - Card, - Group, - PasswordInput, - Stack, - Stepper, - Table, - Text, - TextInput, - Title, - Tooltip, -} from "@mantine/core"; +import { Badge, Button, Card, Group, Stack, Stepper, Table, Text, TextInput, Title, Tooltip } from "@mantine/core"; import { useListState } from "@mantine/hooks"; import { IconPlus, IconUserCheck } from "@tabler/icons-react"; import { z } from "zod/v4"; @@ -24,10 +11,11 @@ import type { GroupPermissionKey } from "@homarr/definitions"; import { everyoneGroup, groupPermissions } from "@homarr/definitions"; import type { IsValid } from "@homarr/form"; import { useZodForm } from "@homarr/form"; +import { UserCreatePasswordFields } from "@homarr/forms-collection"; import { useModalAction } from "@homarr/modals"; import { showErrorNotification } from "@homarr/notifications"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; -import { CustomPasswordInput, UserAvatar } from "@homarr/ui"; +import { UserAvatar } from "@homarr/ui"; import { createCustomErrorParams } from "@homarr/validation/form/i18n"; import { userPasswordSchema } from "@homarr/validation/user"; @@ -167,18 +155,10 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC - - diff --git a/apps/nextjs/src/app/[locale]/manage/users/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/page.tsx index 4059b9972..6c33eaba9 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/page.tsx @@ -6,6 +6,7 @@ import { isProviderEnabled } from "@homarr/auth/server"; import { getScopedI18n } from "@homarr/translation/server"; import { Link } from "@homarr/ui"; +import { TourTarget } from "~/components/layout/header/tour-target"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { createMetaTitle } from "~/metadata"; @@ -38,14 +39,18 @@ export default async function UsersPage() { title={t("list.title")} primaryAction={ credentialsProviderEnabled ? ( - - {t("create.title")} - + + + {t("create.title")} + + ) : undefined } floatingPrimaryAction={credentialsProviderEnabled} > - + + + ); } diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 3b79dafd6..1c0c4100a 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -85,6 +85,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP ), integrationSupport: "supportedIntegrations" in currentDefinition, settings, + appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, { title(t) { diff --git a/apps/nextjs/src/components/board/items/item-move-modal.tsx b/apps/nextjs/src/components/board/items/item-move-modal.tsx index c1e03ba7c..38e362ede 100644 --- a/apps/nextjs/src/components/board/items/item-move-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-move-modal.tsx @@ -1,11 +1,11 @@ import { useCallback, useRef } from "react"; -import { Button, Grid, Group, NumberInput, Stack } from "@mantine/core"; +import { Grid, NumberInput, Stack } from "@mantine/core"; import { z } from "zod/v4"; import { useZodForm } from "@homarr/form"; import type { GridStack } from "@homarr/gridstack"; -import { createModal } from "@homarr/modals"; -import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; +import { useI18n } from "@homarr/translation/client"; import type { Item, SectionItem } from "~/app/[locale]/boards/_types"; @@ -16,9 +16,7 @@ interface InnerProps { } export const ItemMoveModal = createModal(({ actions, innerProps }) => { - const tCommon = useScopedI18n("common"); const t = useI18n(); - // Keep track of the maximum width based on the x offset const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset); const form = useZodForm( z.object({ @@ -38,7 +36,6 @@ export const ItemMoveModal = createModal(({ actions, innerProps }) = height: innerProps.item.height, }, onValuesChange(values, previous) { - // Update the maximum width when the x offset changes if (values.xOffset !== previous.xOffset) { maxWidthRef.current = innerProps.columnCount - values.xOffset; } @@ -70,6 +67,7 @@ export const ItemMoveModal = createModal(({ actions, innerProps }) = (({ actions, innerProps }) = - - - - + ); @@ -106,5 +99,5 @@ export const ItemMoveModal = createModal(({ actions, innerProps }) = defaultTitle(t) { return t("item.moveResize.title"); }, - size: "lg", + size: modalSizeForm, }); diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index 23c9ddec6..84947d150 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -1,14 +1,14 @@ import { useMemo, useState } from "react"; -import { Avatar, Button, Card, Center, Grid, Group, Input, Stack, Text, Tooltip } from "@mantine/core"; -import { IconSearch } from "@tabler/icons-react"; +import { Avatar, Box, Button, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { createId, objectEntries } from "@homarr/common"; import { getIconUrl, getIntegrationName } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import { createModal, useModalAction } from "@homarr/modals"; +import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; +import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui"; import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { WidgetEditModal } from "@homarr/widgets/modals"; @@ -86,32 +86,31 @@ export const ItemSelectModal = createModal(({ actions }) => { }; return ( - - setSearch(event.currentTarget.value)} - leftSection={} - placeholder={`${t("item.create.search")}...`} - data-autofocus - onKeyDown={(event) => { - // Add item if there is only one item in the list and user presses Enter - if (event.key === "Enter" && filteredItems.length === 1) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - handleAdd(filteredItems[0]!.kind); - } - }} - /> + { + if (event.key === "Enter" && filteredItems.length === 1) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + handleAdd(filteredItems[0]!.kind); + } + }} + > + {filteredItems.map((item) => ( + handleAdd(item.kind)} /> + ))} - - {filteredItems.map((item) => ( - handleAdd(item.kind)} /> - ))} - - + {filteredItems.length === 0 && ( +
+ {t("common.noResults")} +
+ )} + ); }).withOptions({ defaultTitle: (t) => t("item.create.title"), - size: "xl", + size: modalSizeSelect, }); const WidgetItem = ({ @@ -130,46 +129,58 @@ const WidgetItem = ({ const t = useI18n(); return ( - - - - -
- -
- - {item.name} - - - {item.description} - -
- - -
-
-
+ e.currentTarget.style.setProperty("--_hover-opacity", "1")} + onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} + > + + + + + {item.name} + + + + + {item.description} + + + + + + + + ); }; -interface SupportedIntegrationsProps { - integrations: IntegrationKind[]; - mt: string; -} - -const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps) => { +const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => { if (integrations.length === 0) { return null; } - // When there are 8 or more integrations, we show 6 and a "+X" avatar. Otherwise, we show all integrations. const countToShow = integrations.length >= 8 ? 6 : 7; const moreCount = integrations.length - countToShow; return ( -
+ {integrations.slice(0, countToShow).map((integration) => ( @@ -195,6 +206,6 @@ const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps) )} -
+ ); }; diff --git a/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx b/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx index 1f990eec0..fa14ea8ab 100644 --- a/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx +++ b/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx @@ -1,8 +1,8 @@ -import { Button, Group, Stack, TextInput } from "@mantine/core"; +import { Stack, TextInput } from "@mantine/core"; import { z } from "zod/v4"; import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; +import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; interface Category { @@ -36,13 +36,10 @@ export const CategoryEditModal = createModal(({ actions, innerProps > - - - - + ); -}).withOptions({}); +}).withOptions({ + size: modalSizeForm, +}); diff --git a/apps/nextjs/src/components/board/sections/dynamic/dynamic-edit-modal.tsx b/apps/nextjs/src/components/board/sections/dynamic/dynamic-edit-modal.tsx index f80a833d6..c2f929b41 100644 --- a/apps/nextjs/src/components/board/sections/dynamic/dynamic-edit-modal.tsx +++ b/apps/nextjs/src/components/board/sections/dynamic/dynamic-edit-modal.tsx @@ -1,10 +1,10 @@ "use client"; -import { Button, CloseButton, ColorInput, Group, Stack, TextInput, useMantineTheme } from "@mantine/core"; +import { CloseButton, ColorInput, Stack, TextInput, useMantineTheme } from "@mantine/core"; import type { z } from "zod/v4"; import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; +import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; import { TextMultiSelect } from "@homarr/ui"; import { dynamicSectionOptionsSchema } from "@homarr/validation/shared"; @@ -31,7 +31,7 @@ export const DynamicSectionEditModal = createModal(({ actions, inner })} > - + (({ actions, inner } {...form.getInputProps("borderColor")} /> - - - - - - + ); @@ -63,5 +56,5 @@ export const DynamicSectionEditModal = createModal(({ actions, inner defaultTitle(t) { return t("item.edit.title"); }, - size: "lg", + size: modalSizeForm, }); diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts new file mode 100644 index 000000000..704449b34 --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -0,0 +1,95 @@ +import { objectEntries } from "@homarr/common"; +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import { + featuredIntegrations, + getIntegrationName, + hiddenFromOnboarding, + integrationDefs, + integrationKinds, +} from "@homarr/definitions"; +import { widgetImports } from "@homarr/widgets"; + +const buildWidgetsByIntegration = () => { + const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record< + IntegrationKind, + WidgetKind[] + >; + + for (const [widgetKind, widget] of objectEntries(widgetImports)) { + const supported = + "supportedIntegrations" in widget.definition + ? (widget.definition.supportedIntegrations as IntegrationKind[]) + : []; + for (const kind of supported) { + if (kind in map) { + map[kind].push(widgetKind); + } + } + } + return map; +}; + +export const widgetsByIntegration = buildWidgetsByIntegration(); + +export const categoryTranslationKeys: Record = { + dnsHole: "integration.category.dnsHole", + mediaService: "integration.category.mediaService", + calendar: "integration.category.calendar", + mediaSearch: "integration.category.mediaSearch", + mediaRelease: "integration.category.mediaRelease", + mediaRequest: "integration.category.mediaRequest", + downloadClient: "integration.category.downloadClient", + usenet: "integration.category.usenet", + torrent: "integration.category.torrent", + miscellaneous: "integration.category.miscellaneous", + smartHomeServer: "integration.category.smartHomeServer", + indexerManager: "integration.category.indexerManager", + healthMonitoring: "integration.category.healthMonitoring", + search: "integration.category.search", + mediaTranscoding: "integration.category.mediaTranscoding", + networkController: "integration.category.networkController", + releasesProvider: "integration.category.releasesProvider", + notifications: "integration.category.notifications", + firewall: "integration.category.firewall", + timetable: "integration.category.timetable", + photoService: "integration.category.photoService", + notes: "integration.category.notes", + mediaMonitoring: "integration.category.mediaMonitoring", + speedtest: "integration.category.speedtest", + analytics: "integration.category.analytics", +}; + +export interface IntegrationGridItem { + kind: IntegrationKind; + name: string; + categories: string[]; + widgets: WidgetKind[]; +} + +export const buildSortedIntegrations = ( + options: { enableMockIntegration?: boolean; onboarding?: boolean } = {}, +): IntegrationGridItem[] => + integrationKinds + .filter((kind) => { + if (options.onboarding && hiddenFromOnboarding.has(kind)) return false; + if (options.onboarding && widgetsByIntegration[kind].length === 0) return false; + if (!options.enableMockIntegration && kind === "mock") return false; + return true; + }) + .map((kind) => ({ + kind, + name: getIntegrationName(kind), + categories: [...new Set(integrationDefs[kind].category.flat())] as string[], + widgets: widgetsByIntegration[kind], + })) + .sort((left, right) => { + const leftIdx = featuredIntegrations.indexOf(left.kind); + const rightIdx = featuredIntegrations.indexOf(right.kind); + if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx; + if (leftIdx !== -1) return -1; + if (rightIdx !== -1) return 1; + return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); + }); + +export const filterIntegrations = (items: IntegrationGridItem[], search: string) => + items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim())); diff --git a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx new file mode 100644 index 000000000..cbd347ba1 --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; +import { IconBrandDocker, IconCheck, IconPuzzle } from "@tabler/icons-react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { IntegrationAvatar, SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; + +import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared"; + +interface IntegrationMultiSelectGridProps { + selectedKinds: IntegrationKind[]; + onSelectionChange: (kinds: IntegrationKind[]) => void; + enableMockIntegration?: boolean; + onboarding?: boolean; + detectedKinds?: Set; +} + +export const IntegrationMultiSelectGrid = ({ + selectedKinds: selectedKindsArray, + onSelectionChange, + enableMockIntegration = false, + onboarding = false, + detectedKinds, +}: IntegrationMultiSelectGridProps) => { + const [search, setSearch] = useState(""); + const selectedKinds = useMemo(() => new Set(selectedKindsArray), [selectedKindsArray]); + const t = useI18n(); + + const toggleKind = useCallback( + (kind: IntegrationKind) => { + const current = new Set(selectedKindsArray); + if (current.has(kind)) { + current.delete(kind); + } else { + current.add(kind); + } + onSelectionChange(Array.from(current)); + }, + [selectedKindsArray, onSelectionChange], + ); + + const integrations = useMemo( + () => buildSortedIntegrations({ enableMockIntegration, onboarding }), + [enableMockIntegration, onboarding], + ); + + const sorted = useMemo(() => { + if (!detectedKinds || detectedKinds.size === 0) return integrations; + const detected = integrations.filter((i) => detectedKinds.has(i.kind)); + const rest = integrations.filter((i) => !detectedKinds.has(i.kind)); + return [...detected, ...rest]; + }, [integrations, detectedKinds]); + + const filtered = useMemo(() => filterIntegrations(sorted, search), [sorted, search]); + + return ( + + {filtered.map((integration) => { + const isSelected = selectedKinds.has(integration.kind); + const isDetected = detectedKinds?.has(integration.kind) ?? false; + return ( + toggleKind(integration.kind)} + withBorder + > + + + + + {integration.name} + + {isSelected && } + + + + {isDetected && ( + }> + {t("integration.grid.detected")} + + )} + + {integration.categories.slice(0, 2).map((cat) => ( + + {categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat} + + ))} + + {integration.widgets.length > 0 ? ( + + {integration.widgets.map((widgetKind) => ( + + {t(`widget.${widgetKind}.name`)} + + ))} + + } + > + }> + {t("integration.grid.widgetCount", { count: String(integration.widgets.length) })} + + + ) : ( + + {t("integration.grid.noWidgets")} + + )} + +
+ + ); + })} + + {filtered.length === 0 && ( +
+ {t("common.noResults")} +
+ )} + + ); +}; diff --git a/apps/nextjs/src/components/integration/integration-select-grid.tsx b/apps/nextjs/src/components/integration/integration-select-grid.tsx new file mode 100644 index 000000000..f4905dd2a --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-select-grid.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; +import { IconPuzzle } from "@tabler/icons-react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { IntegrationAvatar, SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; + +import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared"; + +interface IntegrationSelectGridProps { + onSelect: (kind: IntegrationKind) => void; + enableMockIntegration?: boolean; +} + +export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false }: IntegrationSelectGridProps) => { + const [search, setSearch] = useState(""); + const t = useI18n(); + + const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration }), [enableMockIntegration]); + const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]); + + return ( + + {filtered.map((integration) => ( + onSelect(integration.kind)} + withBorder + > + + + + + {integration.name} + + + + + + {integration.categories.slice(0, 2).map((cat) => ( + + {categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat} + + ))} + + {integration.widgets.length > 0 ? ( + + {integration.widgets.map((widgetKind) => ( + + {t(`widget.${widgetKind}.name`)} + + ))} + + } + > + }> + {t("integration.grid.widgetCount", { count: String(integration.widgets.length) })} + + + ) : ( + + {t("integration.grid.noWidgets")} + + )} + +
+ + ))} + + {filtered.length === 0 && ( +
+ {t("common.noResults")} +
+ )} + + ); +}; diff --git a/apps/nextjs/src/components/integration/integration-select-modal.tsx b/apps/nextjs/src/components/integration/integration-select-modal.tsx new file mode 100644 index 000000000..3410824f0 --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-select-modal.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useState } from "react"; +import { Group, ScrollArea, Title } from "@mantine/core"; +import { IconArrowLeft } from "@tabler/icons-react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { getIntegrationName } from "@homarr/definitions"; +import { createModal, modalSizeSelect } from "@homarr/modals"; +import { IntegrationAvatar } from "@homarr/ui"; + +import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form"; +import { IntegrationSelectGrid } from "./integration-select-grid"; + +interface IntegrationSelectModalProps { + onSuccess?: () => void; + enableMockIntegration?: boolean; +} + +export const IntegrationSelectModal = createModal(({ actions, innerProps }) => { + const [step, setStep] = useState<"select" | "form">("select"); + const [selectedKind, setSelectedKind] = useState(null); + + const handleSelect = (kind: IntegrationKind) => { + setSelectedKind(kind); + setStep("form"); + }; + + const handleBack = () => { + setStep("select"); + setSelectedKind(null); + }; + + const handleSuccess = () => { + innerProps.onSuccess?.(); + actions.closeModal(); + }; + + if (step === "form" && selectedKind) { + return ( + + + + + {getIntegrationName(selectedKind)} + + + + ); + } + + return ; +}).withOptions({ + defaultTitle: (t) => t("integration.action.create"), + size: modalSizeSelect, +}); diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx index f1e513f30..32ffa81d7 100644 --- a/apps/nextjs/src/components/layout/header.tsx +++ b/apps/nextjs/src/components/layout/header.tsx @@ -6,6 +6,7 @@ import { Link } from "@homarr/ui"; import { ClientBurger } from "./header/burger"; import { DesktopSearchInput, MobileSearchButton } from "./header/search"; +import { TourTarget } from "./header/tour-target"; import { UserButton } from "./header/user"; import { HomarrLogoWithTitle } from "./logo/homarr-logo"; @@ -25,11 +26,15 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => { {logo ?? } - + + + {actions} - + + + diff --git a/apps/nextjs/src/components/layout/header/tour-target.tsx b/apps/nextjs/src/components/layout/header/tour-target.tsx new file mode 100644 index 000000000..b23572d1e --- /dev/null +++ b/apps/nextjs/src/components/layout/header/tour-target.tsx @@ -0,0 +1,11 @@ +"use client"; + +import type { ReactNode } from "react"; +import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; +import { Box } from "@mantine/core"; + +export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => ( + + {children} + +); diff --git a/apps/nextjs/src/components/layout/navigation-link.tsx b/apps/nextjs/src/components/layout/navigation-link.tsx index 2d98d42ec..80d58fa07 100644 --- a/apps/nextjs/src/components/layout/navigation-link.tsx +++ b/apps/nextjs/src/components/layout/navigation-link.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { usePathname } from "next/navigation"; +import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; import { NavLink } from "@mantine/core"; import { Link } from "@homarr/ui"; @@ -9,9 +10,15 @@ import { Link } from "@homarr/ui"; export const CommonNavLink = (props: ClientNavigationLink) => "href" in props ? : ; +const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => { + if (!id) return <>{children}; + return {children}; +}; + const NavLinkHref = (props: NavigationLinkHref) => { const pathname = usePathname(); - return props.external ? ( + const tourId = props["data-onboarding-tour-id"]; + const link = props.external ? ( ) : ( { label={props.label} leftSection={props.icon} href={props.href} - active={pathname === props.href} + active={pathname === props.href || pathname.startsWith(`${props.href}/`)} /> ); + return {link}; }; const NavLinkWithItems = (props: NavigationLinkWithItems) => { const pathname = usePathname(); - const isActive = props.items.some((item) => item.href === pathname); - return ( + const isActive = props.items.some((item) => pathname === item.href || pathname.startsWith(`${item.href}/`)); + const nav = ( {props.items.map((item) => ( ))} ); + return {nav}; }; interface CommonNavigationLinkProps { label: string; icon: ReactNode; + "data-onboarding-tour-id"?: string; } interface NavigationLinkHref extends CommonNavigationLinkProps { diff --git a/apps/nextjs/src/components/layout/navigation.tsx b/apps/nextjs/src/components/layout/navigation.tsx index 47f0db1b3..692c54c51 100644 --- a/apps/nextjs/src/components/layout/navigation.tsx +++ b/apps/nextjs/src/components/layout/navigation.tsx @@ -27,7 +27,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi return null; } - const { icon: TablerIcon, iconProps, ...props } = link; + const { icon: TablerIcon, iconProps, "data-onboarding-tour-id": tourId, ...props } = link; const Icon = typeof TablerIcon === "string" ? ( @@ -38,17 +38,20 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi if ("items" in props) { clientLink = { ...props, + "data-onboarding-tour-id": tourId, items: props.items .filter((item) => !item.hidden) .map((item) => { + const { "data-onboarding-tour-id": itemTourId, ...rest } = item; return { - ...item, + ...rest, + "data-onboarding-tour-id": itemTourId, icon: , }; }), } as ClientNavigationLink; } else { - clientLink = props as ClientNavigationLink; + clientLink = { ...props, "data-onboarding-tour-id": tourId } as ClientNavigationLink; } return ; })} @@ -63,6 +66,7 @@ interface CommonNavigationLinkProps { icon: TablerIcon | string; iconProps?: TablerIconProps; hidden?: boolean; + "data-onboarding-tour-id"?: string; } interface NavigationLinkHref extends CommonNavigationLinkProps { diff --git a/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx b/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx index f6310d481..d200aa966 100644 --- a/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx +++ b/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx @@ -58,19 +58,23 @@ export const DynamicBreadcrumb = ({ } const href = `/${pathnameParts.slice(0, index + 1).join("/")}`; const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`; + const mappedValue = dynamicMappings?.get(pathnamePart); + + if (mappedValue) { + if (nonInteractable?.includes(pathnamePart)) { + return {mappedValue}; + } + return ( + + {mappedValue} + + ); + } if (nonInteractable?.includes(pathnamePart)) { return {t(`${translationKey}.label` as TranslationKeys)}; } - if (dynamicMappings?.has(pathnamePart)) { - return ( - - {dynamicMappings.get(pathnamePart)} - - ); - } - return ( {t(`${translationKey}.label` as TranslationKeys)} diff --git a/apps/nextjs/src/components/onboarding/board-tour.tsx b/apps/nextjs/src/components/onboarding/board-tour.tsx new file mode 100644 index 000000000..d7f5d2765 --- /dev/null +++ b/apps/nextjs/src/components/onboarding/board-tour.tsx @@ -0,0 +1,108 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { useMemo } from "react"; +import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour"; + +import { clientApi } from "@homarr/api/client"; +import { useRequiredBoard } from "@homarr/boards/context"; +import { createDocumentationLink } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { useBoardPermissions } from "~/components/board/permissions/client"; +import { TourShell } from "./tour-shell"; +import { TourStepContent } from "./tour-step-content"; + +export const BoardTourProvider = ({ children }: PropsWithChildren) => { + const t = useScopedI18n("onboardingTour.board"); + const board = useRequiredBoard(); + const { hasChangeAccess } = useBoardPermissions(board); + const utils = clientApi.useUtils(); + const { data: tourStatus } = clientApi.user.getTourStatus.useQuery(); + const { mutate: completeTour } = clientApi.user.completeTour.useMutation({ + onSuccess() { + void utils.user.getTourStatus.invalidate(); + }, + }); + + const started = tourStatus !== undefined && !tourStatus.completedBoardTour; + + const handleEnd = () => { + utils.user.getTourStatus.setData(undefined, { + completedManageTour: tourStatus?.completedManageTour ?? false, + completedBoardTour: true, + }); + completeTour({ tour: "board" }); + }; + + const steps = useMemo(() => { + const allSteps: (OnboardingTourStep & { requiresChangeAccess?: boolean })[] = [ + { + id: "board-search", + title: t("welcome.title"), + content: ( + + ), + }, + { + id: "board-edit-mode", + title: t("editMode.title"), + requiresChangeAccess: true, + content: ( + + ), + }, + { + id: "board-settings", + title: t("settings.title"), + requiresChangeAccess: true, + content: ( + + ), + }, + { + id: "board-switcher", + title: t("switcher.title"), + content: ( + + ), + }, + { + id: "board-user-menu", + title: t("userMenu.title"), + content: ( + + ), + }, + ]; + + return allSteps.filter((step) => !step.requiresChangeAccess || hasChangeAccess) as OnboardingTourStep[]; + }, [hasChangeAccess, t]); + + return ( + + {children} + + ); +}; diff --git a/apps/nextjs/src/components/onboarding/manage-tour.tsx b/apps/nextjs/src/components/onboarding/manage-tour.tsx new file mode 100644 index 000000000..70ca61084 --- /dev/null +++ b/apps/nextjs/src/components/onboarding/manage-tour.tsx @@ -0,0 +1,220 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour"; +import { useMediaQuery } from "@mantine/hooks"; +import { + IconAffiliateFilled, + IconAppsFilled, + IconLayoutDashboardFilled, + IconPlus, + IconUserFilled, +} from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { createDocumentationLink } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { TourShell } from "./tour-shell"; +import { TourStepContent } from "./tour-step-content"; + +interface ManageTourProviderProps extends PropsWithChildren { + isAdmin: boolean; +} + +const stepRoutes: Record = { + "manage-welcome": "/manage", + "manage-boards-list": "/manage/boards", + "manage-boards-create": "/manage/boards", + "manage-apps-list": "/manage/apps", + "manage-apps-create": "/manage/apps", + "manage-integrations-list": "/manage/integrations", + "manage-integrations-create": "/manage/integrations", + "manage-users-list": "/manage/users", + "manage-users-create": "/manage/users", +}; + +const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = { + withReveal: false, + popoverProps: { + position: "bottom", + middlewares: { shift: { padding: 16 }, flip: true }, + }, +}; + +export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => { + const t = useScopedI18n("onboardingTour.manage"); + const utils = clientApi.useUtils(); + const { data: tourStatus } = clientApi.user.getTourStatus.useQuery(); + const { mutate: completeTour } = clientApi.user.completeTour.useMutation({ + onSuccess() { + void utils.user.getTourStatus.invalidate(); + }, + }); + const isMobile = useMediaQuery("(max-width: 48em)"); + const pathname = usePathname(); + const router = useRouter(); + + const isManageHome = /^(\/[^/]+)?\/manage\/?$/.test(pathname); + const isManageSection = /^(\/[^/]+)?\/manage(\/.*)?$/.test(pathname); + + const [tourActive, setTourActive] = useState(false); + const [tourDismissed, setTourDismissed] = useState(false); + const wasManageTourCompletedRef = useRef(tourStatus?.completedManageTour); + + useEffect(() => { + if (tourDismissed) return; + if (isManageHome && tourStatus !== undefined && !tourStatus.completedManageTour && !isMobile) { + setTourActive(true); + } + }, [isManageHome, tourStatus, isMobile, tourDismissed]); + + useEffect(() => { + if (!isManageSection) { + setTourActive(false); + } + }, [isManageSection]); + + useEffect(() => { + const wasCompleted = wasManageTourCompletedRef.current; + const isCompleted = tourStatus?.completedManageTour; + if (wasCompleted === true && isCompleted === false) { + setTourDismissed(false); + } + wasManageTourCompletedRef.current = isCompleted; + }, [tourStatus?.completedManageTour]); + + const handleEnd = useCallback(() => { + setTourDismissed(true); + setTourActive(false); + utils.user.getTourStatus.setData(undefined, { + completedManageTour: true, + completedBoardTour: tourStatus?.completedBoardTour ?? false, + }); + completeTour({ tour: "manage" }); + router.push("/manage"); + }, [completeTour, router, tourStatus?.completedBoardTour, utils.user.getTourStatus]); + + const steps = useMemo(() => { + const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [ + { + id: "manage-welcome", + title: t("welcome.title"), + content: ( + + ), + }, + { + id: "manage-boards-list", + title: t("boardsList.title"), + content: ( + } + /> + ), + }, + { + id: "manage-boards-create", + title: t("boardsCreate.title"), + content: ( + } + /> + ), + }, + { + id: "manage-apps-list", + title: t("appsList.title"), + content: ( + } + /> + ), + }, + { + id: "manage-apps-create", + title: t("appsCreate.title"), + content: ( + } + /> + ), + }, + { + id: "manage-integrations-list", + title: t("integrationsList.title"), + content: ( + } + /> + ), + }, + { + id: "manage-integrations-create", + title: t("integrationsCreate.title"), + content: ( + } + /> + ), + }, + { + id: "manage-users-list", + title: t("usersList.title"), + adminOnly: true, + focusRevealProps: usersStepFocusRevealProps, + content: ( + } + /> + ), + }, + { + id: "manage-users-create", + title: t("usersCreate.title"), + adminOnly: true, + focusRevealProps: usersStepFocusRevealProps, + content: ( + } + /> + ), + }, + ]; + + return allSteps.filter((step) => !step.adminOnly || isAdmin) as OnboardingTourStep[]; + }, [isAdmin, t]); + + return ( + + {children} + + ); +}; diff --git a/apps/nextjs/src/components/onboarding/tour-shell.tsx b/apps/nextjs/src/components/onboarding/tour-shell.tsx new file mode 100644 index 000000000..5387c2176 --- /dev/null +++ b/apps/nextjs/src/components/onboarding/tour-shell.tsx @@ -0,0 +1,197 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; +import type { OnboardingTourController, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour"; +import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; +import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core"; +import type { FloatingPosition } from "@mantine/core"; + +import { useI18n } from "@homarr/translation/client"; + +import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; + +interface TourShellProps extends PropsWithChildren { + steps: OnboardingTourStep[]; + started: boolean; + onEnd: () => void; + stepRoutes?: Record; + position?: FloatingPosition | Record; +} + +type TourController = OnboardingTourController; + +const stepIndexFromController = (controller: TourController) => controller.currentStepIndex ?? 0; + +const stepAtOffset = (controller: TourController, offset: number) => { + const index = stepIndexFromController(controller) + offset; + return controller.tour[index]; +}; + +const pollForElement = (targetId: string, callback: () => void) => { + const interval = setInterval(() => { + const targetElement = document.querySelector(`[data-tour-target="${targetId}"]`); + if (targetElement) { + clearInterval(interval); + callback(); + } + }, 50); + const timeout = setTimeout(() => clearInterval(interval), 5000); + return () => { + clearInterval(interval); + clearTimeout(timeout); + }; +}; + +const isTypingTarget = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable; +}; + +interface TourForwardButtonProps { + label: string; + onClick: () => void; +} + +const TourForwardButton = ({ label, onClick }: TourForwardButtonProps) => { + return ( + + ); +}; + +interface TourDoneButtonProps { + label: string; + onClick: () => void; +} + +const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => { + return ( + + ); +}; + +export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => { + const t = useI18n(); + const router = useRouter(); + const forwardActionRef = useRef<(() => void) | null>(null); + + const navigateAndAdvance = useCallback( + (currentStepId: string | undefined, targetStepId: string | undefined, advance: () => void) => { + if (!stepRoutes || !targetStepId) { + advance(); + return; + } + + const currentRoute = currentStepId ? stepRoutes[currentStepId] : undefined; + const targetRoute = stepRoutes[targetStepId]; + + if (targetRoute && targetRoute !== currentRoute) { + router.push(targetRoute); + pollForElement(targetStepId, advance); + } else { + advance(); + } + }, + [router, stepRoutes], + ); + + const bindForwardAction = useCallback((controller: TourController, action: () => void) => { + forwardActionRef.current = action; + return action; + }, []); + + useEffect(() => { + if (!started) { + forwardActionRef.current = null; + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return; + if (isTypingTarget(event.target)) return; + event.preventDefault(); + forwardActionRef.current?.(); + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [started]); + + return ( + ( + <> + + + {stepIndexFromController(controller) + 1} + + + / + + + {controller.tour.length} + + + {stepIndexFromController(controller) === 0 && ( +
+ Homarr +
+ )} + + )} + nextStepNavigation={(controller) => { + const nextStep = stepAtOffset(controller, 1); + const action = bindForwardAction(controller, () => { + navigateAndAdvance(controller.currentStep?.id, nextStep?.id, () => controller.nextStep()); + }); + return ; + }} + endStepNavigation={(controller) => { + const action = bindForwardAction(controller, () => controller.endTour()); + return ; + }} + prevStepNavigation={(controller) => ( + + )} + skipNavigation={(controller) => ( + + )} + withPrevButton + focusRevealProps={{ + disableTargetInteraction: true, + popoverProps: { + position: position ?? { base: "bottom", sm: "right" }, + width: 420, + shadow: "xl", + radius: "lg", + middlewares: { shift: { padding: 16 }, flip: true }, + }, + }} + cutoutPadding={12} + cutoutRadius={12} + > + {children} +
+ ); +}; diff --git a/apps/nextjs/src/components/onboarding/tour-step-content.tsx b/apps/nextjs/src/components/onboarding/tour-step-content.tsx new file mode 100644 index 000000000..05b7b4bd3 --- /dev/null +++ b/apps/nextjs/src/components/onboarding/tour-step-content.tsx @@ -0,0 +1,39 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Anchor, Divider, Group, Stack, Text, ThemeIcon } from "@mantine/core"; +import { IconExternalLink } from "@tabler/icons-react"; + +import { useI18n } from "@homarr/translation/client"; + +interface TourStepContentProps { + description: string; + documentationHref: string; + icon?: ReactNode; +} + +export const TourStepContent = ({ description, documentationHref, icon }: TourStepContentProps) => { + const t = useI18n(); + + return ( + + + {icon && ( + + {icon} + + )} + + {description} + + + + + + + {t("onboardingTour.learnMore")} + + + + ); +}; diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx index 8faa8f6fa..71ec2b218 100644 --- a/apps/nextjs/src/components/user-avatar-menu.tsx +++ b/apps/nextjs/src/components/user-avatar-menu.tsx @@ -45,7 +45,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar window.location.assign(logoutUrl); return; } - router.refresh(); + router.push("/auth/login"); }, }); }, [logoutUrl, openModal, router]); diff --git a/apps/nextjs/src/env.ts b/apps/nextjs/src/env.ts index 62b49dee8..96f03e7b7 100644 --- a/apps/nextjs/src/env.ts +++ b/apps/nextjs/src/env.ts @@ -3,6 +3,7 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env" export const env = createEnv({ server: { UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false), + DEMO_MODE: createBooleanSchema(false), }, experimental__runtimeEnv: process.env, }); diff --git a/deployments/prebuilt-debian/package.json b/deployments/prebuilt-debian/package.json index ed451a14e..bc90d7d22 100644 --- a/deployments/prebuilt-debian/package.json +++ b/deployments/prebuilt-debian/package.json @@ -6,9 +6,9 @@ }, "engines": { "node": ">=24.16.0", - "pnpm": ">=10.33.4" + "pnpm": ">=10.34.1" }, - "packageManager": "pnpm@10.33.4", + "packageManager": "pnpm@10.34.1", "pnpm": { "onlyBuiltDependencies": [ "better-sqlite3" diff --git a/docs/README.md b/docs/README.md index e7aeef585..42330bec6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,9 @@

+ + Live Demo 🎮 + • Install 💻 • @@ -246,13 +249,20 @@ + + PeaNUT +
+

PeaNUT

+ + + Pi-hole

Pi-hole

- - + + Plex
@@ -293,15 +303,15 @@

Radarr

- - + + Readarr

Readarr

- - + + SABnzbd
@@ -342,15 +352,15 @@

Speedtest
Tracker

- - + + Tdarr

Tdarr

- - + + Tracearr
diff --git a/e2e/onboarding.spec.ts b/e2e/onboarding.spec.ts index 7e709f425..a8a09773e 100644 --- a/e2e/onboarding.spec.ts +++ b/e2e/onboarding.spec.ts @@ -31,6 +31,7 @@ describe("Onboarding", () => { confirmPassword: "Comp(exP4sswOrd", }); await actions.processSettingsStepAsync(); + await actions.processIntegrationsStepAsync(); // Assert await assertions.assertFinishStepVisibleAsync(); @@ -72,6 +73,7 @@ describe("Onboarding", () => { name: externalGroupName, }); await actions.processSettingsStepAsync(); + await actions.processIntegrationsStepAsync(); // Assert await assertions.assertFinishStepVisibleAsync(); diff --git a/e2e/shared/actions/onboarding-actions.ts b/e2e/shared/actions/onboarding-actions.ts index 6bdb1d2a5..56c09aba7 100644 --- a/e2e/shared/actions/onboarding-actions.ts +++ b/e2e/shared/actions/onboarding-actions.ts @@ -51,4 +51,9 @@ export class OnboardingActions { await this.page.waitForSelector("text=Analytics"); await this.page.locator("css=button[type='submit']").click(); } + + public async processIntegrationsStepAsync() { + await this.page.waitForSelector("text=integrations you'd like to connect"); + await this.page.locator("button", { hasText: "Skip for now" }).click(); + } } diff --git a/e2e/shared/assertions/onboarding-assertions.ts b/e2e/shared/assertions/onboarding-assertions.ts index 63d18ea7c..12cda67d0 100644 --- a/e2e/shared/assertions/onboarding-assertions.ts +++ b/e2e/shared/assertions/onboarding-assertions.ts @@ -3,7 +3,7 @@ import { eq } from "drizzle-orm"; import { expect } from "vitest"; import * as sqliteSchema from "../../../packages/db/schema/sqlite"; -import { OnboardingStep } from "../../../packages/definitions/src"; +import type { OnboardingStep } from "../../../packages/definitions/src"; import { credentialsAdminGroup } from "../../../packages/definitions/src/group"; import type { SqliteDatabase } from "../e2e-db"; @@ -57,6 +57,6 @@ export class OnboardingAssertions { } public async assertFinishStepVisibleAsync() { - await this.page.waitForSelector("text=completed the setup", { timeout: 5000 }); + await this.page.waitForSelector("text=completed the setup", { timeout: 15000 }); } } diff --git a/package.json b/package.json index 89d000401..5c4e75b8d 100644 --- a/package.json +++ b/package.json @@ -60,9 +60,9 @@ }, "engines": { "node": ">=24.16.0", - "pnpm": ">=10.33.4" + "pnpm": ">=10.34.1" }, - "packageManager": "pnpm@10.33.4", + "packageManager": "pnpm@10.34.1", "pnpm": { "onlyBuiltDependencies": [ "@tree-sitter-grammars/tree-sitter-yaml", @@ -76,14 +76,14 @@ "tree-sitter-json" ], "overrides": { - "@babel/helpers@<7.26.10": ">=7.29.2", - "@babel/runtime@<7.26.10": ">=7.29.2", + "@babel/helpers@<7.26.10": ">=7.29.7", + "@babel/runtime@<7.26.10": ">=7.29.7", "axios@>=1.0.0 <1.8.2": ">=1.16.1", "brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6", "brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6", "esbuild@<=0.24.2": ">=0.28.0", "form-data@>=4.0.0 <4.0.4": ">=4.0.5", - "hono@<4.6.5": ">=4.12.22", + "hono@<4.6.5": ">=4.12.23", "linkifyjs@<4.3.2": ">=4.3.3", "nanoid@>=4.0.0 <5.0.9": ">=5.1.11", "prismjs@<1.30.0": ">=1.30.0", @@ -93,7 +93,7 @@ "sha.js@<=2.4.11": ">=2.4.12", "tar-fs@>=3.0.0 <3.0.9": ">=3.1.2", "tar-fs@>=2.0.0 <2.1.3": ">=3.1.2", - "tmp@<=0.2.3": ">=0.2.5", + "tmp@<=0.2.3": ">=0.2.7", "vite@>=5.0.0 <=5.4.18": ">=8.0.14" }, "patchedDependencies": { diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index 7cc81cc57..ba26a264a 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -1,12 +1,14 @@ import { z } from "zod/v4"; -import { createEnv } from "@homarr/core/infrastructure/env"; +import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"; export const env = createEnv({ server: { KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(), + DEMO_MODE: createBooleanSchema(false), }, runtimeEnv: { KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME, + DEMO_MODE: process.env.DEMO_MODE, }, }); diff --git a/packages/api/src/middlewares/docker.ts b/packages/api/src/middlewares/docker.ts index 5a317b95a..e4f04f192 100644 --- a/packages/api/src/middlewares/docker.ts +++ b/packages/api/src/middlewares/docker.ts @@ -2,11 +2,11 @@ import { TRPCError } from "@trpc/server"; import { env } from "@homarr/docker/env"; -import { publicProcedure } from "../trpc"; +import { isDemoMode, publicProcedure } from "../trpc"; export const dockerMiddleware = () => { return publicProcedure.use(async ({ next }) => { - if (env.ENABLE_DOCKER) { + if (env.ENABLE_DOCKER || isDemoMode) { return await next(); } throw new TRPCError({ diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 4527482f0..884998749 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -4,7 +4,11 @@ import { z } from "zod/v4"; import type { Container, ContainerState, Docker, Port } from "@homarr/docker"; import { DockerSingleton } from "@homarr/docker"; -import { dockerContainersRequestHandler } from "@homarr/request-handler/docker"; +import { + dockerContainersRequestHandler, + getContainerLogsAsync, + streamContainerLogsAsync, +} from "@homarr/request-handler/docker"; import { dockerMiddleware } from "../../middlewares/docker"; import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc"; @@ -104,6 +108,84 @@ export const dockerRouter = createTRPCRouter({ const innerHandler = dockerContainersRequestHandler.handler({}); await innerHandler.invalidateAsync(); }), + logs: permissionRequiredProcedure + .requiresPermission("admin") + .concat(dockerMiddleware()) + .input( + z.object({ + id: z.string(), + tail: z.number().min(1).max(1000).optional(), + }), + ) + .query(async ({ input }) => { + const logs = await getContainerLogsAsync(input.id, input.tail ?? 200); + + if (!logs) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Container not found", + }); + } + + return { + logs, + }; + }), + subscribeLogs: permissionRequiredProcedure + .requiresPermission("admin") + .concat(dockerMiddleware()) + .input( + z.object({ + id: z.string(), + tail: z.number().min(1).max(1000).optional(), + }), + ) + .subscription(({ input }) => { + return observable((emit) => { + let cleanupFn: (() => void) | undefined; + let isSubscribed = true; + + const initializeStreamAsync = async () => { + try { + cleanupFn = await streamContainerLogsAsync( + input.id, + input.tail ?? 200, + (data) => { + if (isSubscribed) { + emit.next(data); + } + }, + (err) => { + if (isSubscribed) { + emit.error( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: err.message, + }), + ); + } + }, + ); + } catch (err) { + if (isSubscribed) { + emit.error( + new TRPCError({ + code: "NOT_FOUND", + message: err instanceof Error ? err.message : "Container not found", + }), + ); + } + } + }; + + void initializeStreamAsync(); + + return () => { + isSubscribed = false; + cleanupFn?.(); + }; + }); + }), }); const getContainerOrDefaultAsync = async (instance: Docker, id: string) => { diff --git a/packages/api/src/router/info.ts b/packages/api/src/router/info.ts index 3c205963c..8befca412 100644 --- a/packages/api/src/router/info.ts +++ b/packages/api/src/router/info.ts @@ -1,7 +1,7 @@ import z from "zod/v4"; import packageJson from "../../../../package.json"; -import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc"; export const infoRouter = createTRPCRouter({ getInfo: protectedProcedure @@ -13,4 +13,5 @@ export const infoRouter = createTRPCRouter({ version: packageJson.version, }; }), + isDemoMode: publicProcedure.query(() => isDemoMode), }); diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 5591b74ed..4375644cc 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -1,6 +1,8 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; +import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server"; +import { constructIntegrationPermissions } from "@homarr/auth/shared"; import { createId, objectEntries } from "@homarr/common"; import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; @@ -14,11 +16,9 @@ import { integrations, integrationSecrets, integrationUserPermissions, - searchEngines, } from "@homarr/db/schema"; import type { IntegrationSecretKind } from "@homarr/definitions"; import { - getIconUrl, getIntegrationKindsByCategory, getPermissionsWithParents, integrationCategories, @@ -33,6 +33,7 @@ import { integrationSavePermissionsSchema, integrationUpdateSchema, } from "@homarr/validation/integration"; +import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request"; import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; @@ -41,6 +42,7 @@ import { MissingSecretError, testConnectionAsync } from "./integration-test-conn import { mapTestConnectionError } from "./map-test-connection-error"; const logger = createLogger({ module: "integrationRouter" }); +const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch"); export const integrationRouter = createTRPCRouter({ all: protectedProcedure.query(async ({ ctx }) => { @@ -80,7 +82,7 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); @@ -128,7 +130,7 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); @@ -179,7 +181,7 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); @@ -314,20 +316,7 @@ export const integrationRouter = createTRPCRouter({ url: input.url, }); - if ( - input.attemptSearchEngineCreation && - integrationDefs[input.kind].category.flatMap((category) => category).includes("search") - ) { - const icon = getIconUrl(input.kind); - await ctx.db.insert(searchEngines).values({ - id: createId(), - name: input.name, - integrationId, - type: "fromIntegration", - iconUrl: icon, - short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name), - }); - } + // Media request search is a first-class Spotlight action. Do not create a search engine for it. }), update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); @@ -495,7 +484,7 @@ export const integrationRouter = createTRPCRouter({ }); return { - inherited: dbGroupPermissions.sort((permissionA, permissionB) => { + inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), users: userPermissions @@ -503,7 +492,7 @@ export const integrationRouter = createTRPCRouter({ user, permission, })) - .sort((permissionA, permissionB) => { + .toSorted((permissionA, permissionB) => { return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? ""); }), groups: dbGroupIntegrationPermission @@ -514,7 +503,7 @@ export const integrationRouter = createTRPCRouter({ }, permission, })) - .sort((permissionA, permissionB) => { + .toSorted((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), }; @@ -618,8 +607,149 @@ export const integrationRouter = createTRPCRouter({ const integrationInstance = await createIntegrationAsync(ctx.integration); return await integrationInstance.searchAsync(encodeURI(input.query)); }), + mediaRequestSearchTargets: protectedProcedure.query(async ({ ctx }) => { + const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx); + + return integrationsWithAccess.map((integration) => { + const permissions = constructIntegrationPermissions(integration, ctx.session); + + return { + id: integration.id, + name: integration.name, + kind: integration.kind, + url: integration.url, + permissions: { + hasInteractAccess: permissions.hasInteractAccess, + }, + }; + }); + }), + searchMediaRequests: protectedProcedure + .input( + z.object({ + query: z.string(), + integrationIds: z.array(z.string()).optional(), + }), + ) + .query(async ({ ctx, input }) => { + const query = input.query.trim(); + if (query.length === 0) return []; + + const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx, input.integrationIds); + + const results = await Promise.all( + integrationsWithAccess.map(async (integration) => { + const integrationInstance = await createIntegrationAsync({ + id: integration.id, + name: integration.name, + url: integration.url, + kind: integration.kind as (typeof mediaRequestSearchKinds)[number], + externalUrl: integration.app?.href ?? null, + decryptedSecrets: integration.secrets.map((secret) => ({ + ...secret, + value: decryptSecret(secret.value), + })), + }); + const permissions = constructIntegrationPermissions(integration, ctx.session); + + return await integrationInstance.searchAsync(encodeURI(query)).then((searchResults) => + searchResults.map((result) => ({ + ...result, + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + url: integration.url, + permissions: { + hasInteractAccess: permissions.hasInteractAccess, + }, + }, + })), + ); + }), + ); + + return results.flat(); + }), + getMediaRequestOptions: protectedProcedure + .concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr", "seerr")) + .input(mediaRequestOptionsSchema) + .query(async ({ ctx, input }) => { + const integration = await createIntegrationAsync(ctx.integration); + return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId); + }), + requestMedia: protectedProcedure + .concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr", "seerr")) + .input(mediaRequestRequestSchema) + .mutation(async ({ ctx, input }) => { + const integration = await createIntegrationAsync(ctx.integration); + return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); + }), }); +interface IntegrationRouterContext { + db: Database; + session: Parameters[2]; +} + +const getAccessibleMediaRequestSearchIntegrationsAsync = async ( + ctx: IntegrationRouterContext, + integrationIds?: string[], +) => { + const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, ctx.session?.user.id ?? ""), + }); + + const integrationsFromDb = await ctx.db.query.integrations.findMany({ + where: + integrationIds && integrationIds.length > 0 + ? and(inArray(integrations.id, integrationIds), inArray(integrations.kind, mediaRequestSearchKinds)) + : inArray(integrations.kind, mediaRequestSearchKinds), + orderBy: asc(integrations.name), + with: { + app: true, + secrets: true, + items: { + with: { + item: true, + }, + }, + userPermissions: { + where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""), + }, + groupPermissions: { + where: inArray( + integrationGroupPermissions.groupId, + groupsOfCurrentUser.map((group) => group.groupId), + ), + }, + }, + }); + + if (integrationIds && integrationsFromDb.length !== integrationIds.length) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "One or more media request search integrations were not found", + }); + } + + const integrationsWithAccess = await Promise.all( + integrationsFromDb.map(async (integration) => ({ + integration, + hasAccess: await hasQueryAccessToIntegrationsAsync(ctx.db, [integration], ctx.session), + })), + ); + + if (integrationIds && integrationsWithAccess.some(({ hasAccess }) => !hasAccess)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User does not have permission to query one or more media request search integrations", + }); + } + + return integrationsWithAccess.filter(({ hasAccess }) => hasAccess).map(({ integration }) => integration); +}; + interface UpdateSecretInput { integrationId: string; value: string; @@ -640,35 +770,6 @@ interface AddSecretInput { kind: IntegrationSecretKind; } -const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => { - const searchEngines = await db.query.searchEngines.findMany({ - columns: { - short: true, - }, - }); - - const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase()); - const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase(); - - if (!usedShortNames.includes(nameByIntegrationName)) { - return nameByIntegrationName; - } - - // 8 is max length constraint - for (let i = 2; i < 9999999; i++) { - const generatedName = `${nameByIntegrationName}${i}`; - if (usedShortNames.includes(generatedName)) { - continue; - } - - return generatedName; - } - - throw new Error( - "Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.", - ); -}; - const addSecretAsync = async (db: Database, input: AddSecretInput) => { await db.insert(integrationSecrets).values({ kind: input.kind, diff --git a/packages/api/src/router/onboard/onboard-queries.ts b/packages/api/src/router/onboard/onboard-queries.ts index 15f205d8b..242d861e4 100644 --- a/packages/api/src/router/onboard/onboard-queries.ts +++ b/packages/api/src/router/onboard/onboard-queries.ts @@ -76,6 +76,9 @@ const nextSteps: Partial { + const port = ports?.[0]; + if (port?.PublicPort) { + const ip = port.IP && port.IP !== "0.0.0.0" && port.IP !== "::" ? port.IP : host; + return { url: `http://${ip}:${port.PublicPort}`, publishedPort: port.PublicPort }; + } + return { url: "", publishedPort: null }; +}; + export const onboardRouter = createTRPCRouter({ currentStep: publicProcedure.query(async ({ ctx }) => { return await getOnboardingOrFallbackAsync(ctx.db); @@ -33,4 +78,249 @@ export const onboardRouter = createTRPCRouter({ step: "start", }); }), + createIntegration: onboardingProcedure + .requiresStep("integrations") + .input( + z.object({ + name: z.string().nonempty().max(127), + url: z.string().nonempty(), + kind: zodEnumFromArray(integrationKinds), + secrets: z.array( + z.object({ + kind: zodEnumFromArray(integrationSecretKinds), + value: z.string().nonempty(), + }), + ), + }), + ) + .mutation(async ({ ctx, input }) => { + const result = await testConnectionAsync({ + id: "new", + name: input.name, + url: input.url, + kind: input.kind, + secrets: input.secrets, + }).catch((error) => { + if (!(error instanceof MissingSecretError)) throw error; + + throw new TRPCError({ + code: "BAD_REQUEST", + message: error.message, + }); + }); + + if (!result.success) { + return { error: mapTestConnectionError(result.error) }; + } + + const integrationId = createId(); + const appId = createId(); + + await ctx.db.insert(apps).values({ + id: appId, + name: input.name, + iconUrl: getIconUrl(input.kind), + href: input.url, + pingUrl: input.url, + description: null, + }); + + await ctx.db.insert(integrations).values({ + id: integrationId, + name: input.name, + url: input.url, + kind: input.kind, + appId, + }); + + if (input.secrets.length >= 1) { + await ctx.db.insert(integrationSecrets).values( + input.secrets.map((secret) => ({ + kind: secret.kind, + value: encryptSecret(secret.value), + integrationId, + })), + ); + } + }), + discoverDockerServices: onboardingProcedure.requiresStep("integrations").query(async ({ ctx }) => { + const emptyResult = { + integrations: [] as { + containerId: string; + containerName: string; + kind: IntegrationKind; + suggestedUrl: string; + publishedPort: number | null; + iconUrl: string | null; + }[], + apps: [] as { + containerId: string; + containerName: string; + suggestedUrl: string; + publishedPort: number | null; + iconUrl: string | null; + }[], + }; + + try { + const { DockerSingleton, dockerLabels } = await import("@homarr/docker"); + + const dockerInstances = DockerSingleton.getInstances(); + if (dockerInstances.length === 0) { + return { status: "empty" as const, ...emptyResult }; + } + + const results = await Promise.allSettled( + dockerInstances.map(async ({ instance, host }) => { + const containerList = await instance.listContainers({ all: false }); + return containerList.filter((c) => !(dockerLabels.hide in c.Labels)).map((c) => ({ ...c, host })); + }), + ); + + const containers = results.flatMap((r) => (r.status === "fulfilled" ? r.value : [])); + + if (containers.length === 0) { + return { status: "empty" as const, ...emptyResult }; + } + + const likeQueries = containers.map((c) => like(icons.name, `%${extractContainerImageName(c.Image)}%`)); + const dbIcons = likeQueries.length > 0 ? await ctx.db.query.icons.findMany({ where: or(...likeQueries) }) : []; + + const seenKinds = new Set(); + const discoveredIntegrations = emptyResult.integrations; + const discoveredApps = emptyResult.apps; + + const cdnIconUrl = (slug: string) => + `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/${slug}.svg`; + + const strictIconMatch = (imageName: string) => { + const normalized = imageName.toLowerCase().trim(); + if (normalized.length < 3) return null; + const exact = dbIcons.find((icon) => icon.name.toLowerCase() === normalized); + if (exact) return exact.url; + return dbIcons.find((icon) => icon.name.toLowerCase().startsWith(normalized))?.url ?? null; + }; + + for (const container of containers) { + const imageName = extractContainerImageName(container.Image); + const containerName = container.Names[0]?.split("/")[1] ?? "Unknown"; + const dbIcon = strictIconMatch(imageName); + const iconUrl = dbIcon ?? cdnIconUrl(imageName.toLowerCase()); + + const { url: suggestedUrl, publishedPort } = buildSuggestedUrl(container.Ports, container.host); + + const kind = matchIntegrationKindFromContainer({ + image: container.Image, + name: containerName, + }); + + if (kind && !seenKinds.has(kind)) { + seenKinds.add(kind); + discoveredIntegrations.push({ + containerId: container.Id, + containerName, + kind, + suggestedUrl, + publishedPort, + iconUrl, + }); + } else if (!kind && dbIcon) { + discoveredApps.push({ + containerId: container.Id, + containerName, + suggestedUrl, + publishedPort, + iconUrl, + }); + } + } + + return { + status: "success" as const, + integrations: discoveredIntegrations, + apps: discoveredApps, + }; + } catch { + return { status: "unavailable" as const, ...emptyResult }; + } + }), + createAppsFromDiscovery: onboardingProcedure + .requiresStep("integrations") + .input( + z.array( + z.object({ + name: z.string().nonempty().max(127), + href: z.string().nullable(), + iconUrl: z.string().nullable(), + }), + ), + ) + .mutation(async ({ ctx, input }) => { + if (input.length === 0) return; + const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg"; + await ctx.db.insert(apps).values( + input.map((app) => ({ + id: createId(), + name: app.name, + iconUrl: app.iconUrl ?? defaultIcon, + href: app.href, + description: null, + })), + ); + }), + setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => { + const db = ctx.db; + + const allIntegrations = await db.query.integrations.findMany(); + const allApps = await db.query.apps.findMany(); + + const board = await db.query.boards.findFirst({ + with: { + sections: true, + layouts: true, + }, + }); + + if (!board) { + await nextOnboardingStepAsync(db, undefined); + return; + } + + const everyoneGroupRow = await db.query.groups.findFirst({ + where: eq(groups.name, everyoneGroup), + }); + if (everyoneGroupRow && !everyoneGroupRow.homeBoardId) { + await db.update(groups).set({ homeBoardId: board.id }).where(eq(groups.id, everyoneGroupRow.id)); + } + + const section = board.sections.find((boardSection) => boardSection.kind === "empty"); + const layout = board.layouts[0]; + + if (section && layout) { + const existingItems = await db.query.items.findMany({ + where: eq(items.boardId, board.id), + }); + for (const item of existingItems) { + await db.delete(integrationItems).where(eq(integrationItems.itemId, item.id)); + await db.delete(itemLayouts).where(eq(itemLayouts.itemId, item.id)); + } + if (existingItems.length > 0) { + await db.delete(items).where(eq(items.boardId, board.id)); + } + + await placeAllWidgetsAsync( + db, + { + boardId: board.id, + sectionId: section.id, + layoutId: layout.id, + columnCount: layout.columnCount, + }, + allIntegrations, + allApps, + ); + } + + await nextOnboardingStepAsync(db, undefined); + }), }); diff --git a/packages/api/src/router/search-engine/search-engine-router.ts b/packages/api/src/router/search-engine/search-engine-router.ts index ddfc892a9..8c72baf6e 100644 --- a/packages/api/src/router/search-engine/search-engine-router.ts +++ b/packages/api/src/router/search-engine/search-engine-router.ts @@ -6,12 +6,9 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { asc, eq, like } from "@homarr/db"; import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries"; import { searchEngines, users } from "@homarr/db/schema"; -import { createIntegrationAsync } from "@homarr/integrations"; import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common"; import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine"; -import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; const logger = createLogger({ module: "searchEngineRouter" }); @@ -148,20 +145,6 @@ export const searchEngineRouter = createTRPCRouter({ limit: input.limit, }); }), - getMediaRequestOptions: protectedProcedure - .concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr")) - .input(mediaRequestOptionsSchema) - .query(async ({ ctx, input }) => { - const integration = await createIntegrationAsync(ctx.integration); - return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId); - }), - requestMedia: protectedProcedure - .concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr")) - .input(mediaRequestRequestSchema) - .mutation(async ({ ctx, input }) => { - const integration = await createIntegrationAsync(ctx.integration); - return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); - }), create: permissionRequiredProcedure .requiresPermission("search-engine-create") .input(searchEngineManageSchema) diff --git a/packages/api/src/router/test/docker/docker-router.spec.ts b/packages/api/src/router/test/docker/docker-router.spec.ts index 941e696e6..cbea4063b 100644 --- a/packages/api/src/router/test/docker/docker-router.spec.ts +++ b/packages/api/src/router/test/docker/docker-router.spec.ts @@ -13,6 +13,14 @@ import { dockerRouter } from "../../docker/docker-router"; // Mock the auth module to return an empty session vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); vi.mock("@homarr/request-handler/docker", () => ({ + getContainerLogsAsync: async () => { + await Promise.resolve(); + return "logs"; + }, + streamContainerLogsAsync: async () => { + await Promise.resolve(); + return () => undefined; + }, dockerContainersRequestHandler: { handler: () => ({ getCachedOrUpdatedDataAsync: async () => { @@ -65,6 +73,8 @@ const validInputs: { restartAll: { ids: ["1"] }, removeAll: { ids: ["1"] }, invalidate: undefined, + logs: { id: "1", tail: 200 }, + subscribeLogs: { id: "1", tail: 200 }, }; describe("All procedures should only be accessible for users with admin permission", () => { diff --git a/packages/api/src/router/test/integration/integration-router.spec.ts b/packages/api/src/router/test/integration/integration-router.spec.ts index 17927b9c4..a7cdddfa2 100644 --- a/packages/api/src/router/test/integration/integration-router.spec.ts +++ b/packages/api/src/router/test/integration/integration-router.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; import { createId } from "@homarr/common"; import { encryptSecret } from "@homarr/common/server"; -import { apps, integrations, integrationSecrets } from "@homarr/db/schema"; +import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema"; import { createDb } from "@homarr/db/test"; import type { GroupPermissionKey } from "@homarr/definitions"; @@ -59,6 +59,127 @@ describe("all should return all integrations", () => { }); }); +describe("mediaRequestSearchTargets should return accessible media search integrations", () => { + test("with integration use access should only return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(["integration-use-all"]), + }); + + await db.insert(integrations).values([ + { + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }, + { + id: "2", + name: "Jellyfin", + kind: "jellyfin", + url: "http://jellyfin.local", + }, + ]); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("1"); + expect(result[0]!.kind).toBe("jellyseerr"); + }); + + test("without access should not return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(0); + }); + + test("with direct integration use access should return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + await db.insert(users).values({ id: defaultUserId }); + await db.insert(integrationUserPermissions).values({ + integrationId: "1", + userId: defaultUserId, + permission: "use", + }); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("1"); + }); +}); + +describe("searchMediaRequests should validate scoped integrations", () => { + test("with a non media search integration should throw not found", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(["integration-use-all"]), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyfin", + kind: "jellyfin", + url: "http://jellyfin.local", + }); + + await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow( + "One or more media request search integrations were not found", + ); + }); + + test("without query access to a scoped media search integration should throw forbidden", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + + await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow( + "User does not have permission to query one or more media request search integrations", + ); + }); +}); + describe("byId should return an integration by id", () => { test("with full access should return an integration by id", async () => { const db = createDb(); @@ -208,7 +329,7 @@ describe("create should create a new integration", () => { expect(dbSecret!.updatedAt).toEqual(fakeNow); }); - test("with create integration access should create a new integration when creating search engine", async () => { + test("with create integration access should not create a search engine for media request search integrations", async () => { const db = createDb(); const caller = integrationRouter.createCaller({ db, @@ -243,12 +364,7 @@ describe("create should create a new integration", () => { expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/); expect(dbSecret!.updatedAt).toEqual(fakeNow); - expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id); - expect(dbSearchEngine!.short).toBe("j"); - expect(dbSearchEngine!.name).toBe(input.name); - expect(dbSearchEngine!.iconUrl).toBe( - "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg", - ); + expect(dbSearchEngine).toBeUndefined(); }); test("with create integration access should create a new integration with new linked app", async () => { diff --git a/packages/api/src/router/test/user.spec.ts b/packages/api/src/router/test/user.spec.ts index b3b262846..16cabfd52 100644 --- a/packages/api/src/router/test/user.spec.ts +++ b/packages/api/src/router/test/user.spec.ts @@ -81,13 +81,7 @@ describe("initUser should initialize the first user", () => { await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch"); }); - it.each([ - ["aB2%"], // too short - ["abc123DEF"], // does not contain special characters - ["abcDEFghi+"], // does not contain numbers - ["ABC123+/-"], // does not contain lowercase - ["abc123+/-"], // does not contain uppercase - ])("should throw error that password requirements do not match for '%s' as password", async (password) => { + it.each([["aB2%"], ["short"]])("should reject passwords shorter than 8 characters for '%s'", async (password) => { const db = createDb(); await createOnboardingStepAsync(db, "user"); const caller = userRouter.createCaller({ @@ -103,7 +97,55 @@ describe("initUser should initialize the first user", () => { confirmPassword: password, }); - await expect(actAsync()).rejects.toThrow("passwordRequirements"); + await expect(actAsync()).rejects.toThrow(); + }); + + it("should accept passwords without complexity requirements", async () => { + const db = createDb(); + await createOnboardingStepAsync(db, "user"); + const caller = userRouter.createCaller({ + db, + deviceType: undefined, + session: null, + }); + + await caller.initUser({ + username: "test", + password: "abc123DEF", + confirmPassword: "abc123DEF", + }); + + const user = await db.query.users.findFirst({ + columns: { + id: true, + }, + }); + + expect(user).toBeDefined(); + }); + + it("should accept passwords with special characters like LoveHomarr<3", async () => { + const db = createDb(); + await createOnboardingStepAsync(db, "user"); + const caller = userRouter.createCaller({ + db, + deviceType: undefined, + session: null, + }); + + await caller.initUser({ + username: "test", + password: "LoveHomarr<3", + confirmPassword: "LoveHomarr<3", + }); + + const user = await db.query.users.findFirst({ + columns: { + id: true, + }, + }); + + expect(user).toBeDefined(); }); }); diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 9c4f16c80..466b095fd 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -32,6 +32,7 @@ import { import { convertIntersectionToZodObject } from "../schema-merger"; import { createTRPCRouter, + isDemoMode, onboardingProcedure, permissionRequiredProcedure, protectedProcedure, @@ -278,6 +279,8 @@ export const userRouter = createTRPCRouter({ defaultSearchEngineId: true, openSearchInNewTab: true, ddgBangs: true, + completedManageTour: true, + completedBoardTour: true, }), ) .meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } }) @@ -304,6 +307,8 @@ export const userRouter = createTRPCRouter({ defaultSearchEngineId: true, openSearchInNewTab: true, ddgBangs: true, + completedManageTour: true, + completedBoardTour: true, }, where: eq(users.id, input.userId), }); @@ -542,7 +547,7 @@ export const userRouter = createTRPCRouter({ .set({ pingIconsEnabled: input.pingIconsEnabled, }) - .where(eq(users.id, ctx.session.user.id)); + .where(eq(users.id, input.id)); }), changeDdgBangs: protectedProcedure .input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema))) @@ -603,8 +608,48 @@ export const userRouter = createTRPCRouter({ .set({ firstDayOfWeek: input.firstDayOfWeek, }) - .where(eq(users.id, ctx.session.user.id)); + .where(eq(users.id, input.id)); }), + completeTour: protectedProcedure + .input(z.object({ tour: z.enum(["manage", "board"]) })) + .mutation(async ({ input, ctx }) => { + const columnByTour = { + manage: { completedManageTour: true }, + board: { completedBoardTour: true }, + } as const; + + await ctx.db.update(users).set(columnByTour[input.tour]).where(eq(users.id, ctx.session.user.id)); + }), + resetTours: protectedProcedure.mutation(async ({ ctx }) => { + await ctx.db + .update(users) + .set({ + completedManageTour: false, + completedBoardTour: false, + }) + .where(eq(users.id, ctx.session.user.id)); + }), + getTourStatus: protectedProcedure.query(async ({ ctx }) => { + if (isDemoMode) { + return { + completedManageTour: true, + completedBoardTour: true, + }; + } + + const user = await ctx.db.query.users.findFirst({ + columns: { + completedManageTour: true, + completedBoardTour: true, + }, + where: eq(users.id, ctx.session.user.id), + }); + + return { + completedManageTour: user?.completedManageTour ?? false, + completedBoardTour: user?.completedBoardTour ?? false, + }; + }), }); const createUserAsync = async (db: Database, input: Omit, "groupIds">) => { diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index e40c3f000..15f0963b4 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -31,4 +31,5 @@ export const widgetRouter = createTRPCRouter({ tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)), speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)), umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)), + ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)), }); diff --git a/packages/api/src/router/widgets/ups.ts b/packages/api/src/router/widgets/ups.ts new file mode 100644 index 000000000..f1c27c434 --- /dev/null +++ b/packages/api/src/router/widgets/ups.ts @@ -0,0 +1,51 @@ +import { observable } from "@trpc/server/observable"; + +import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import type { UpsSummary } from "@homarr/integrations/types"; +import { upsSummariesRequestHandler } from "@homarr/request-handler/ups"; + +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const upsRouter = createTRPCRouter({ + getSummaries: publicProcedure + .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups"))) + .query(async ({ ctx }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = upsSummariesRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + summaries: data, + updatedAt: timestamp, + }; + }), + ); + + return results; + }), + subscribeSummaries: publicProcedure + .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups"))) + .subscription(({ ctx }) => { + return observable<{ integrationId: string; summaries: UpsSummary[]; timestamp: Date }>((emit) => { + const unsubscribes = ctx.integrations.map((integration) => { + const innerHandler = upsSummariesRequestHandler.handler(integration, {}); + return innerHandler.subscribe((summaries) => { + emit.next({ + integrationId: integration.id, + summaries, + timestamp: new Date(), + }); + }); + }); + + return () => { + unsubscribes.forEach((unsubscribe) => unsubscribe()); + }; + }); + }), +}); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index bedd60f3d..eb4da06c2 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -18,6 +18,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { db } from "@homarr/db"; import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions"; +import { env } from "./env"; import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries"; const logger = createLogger({ module: "trpc" }); @@ -87,6 +88,20 @@ export const createCallerFactory = t.createCallerFactory; */ export const createTRPCRouter = t.router; +export const isDemoMode = env.DEMO_MODE; + +const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => { + if (env.DEMO_MODE && type === "mutation") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Mutations are disabled in demo mode", + }); + } + return next({ ctx }); +}); + +const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure; + /** * Public (unauthed) procedure * @@ -94,19 +109,14 @@ export const createTRPCRouter = t.router; * tRPC API. It does not guarantee that a user querying is authorized, but you * can still access user session data if they are logged in */ -export const publicProcedure = t.procedure; +export const publicProcedure = baseProcedure; -/** - * Reusable middleware that enforces users are logged in before running the - * procedure - */ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { - // infers the `session` as non-nullable session: { ...ctx.session, user: ctx.session.user }, }, }); @@ -121,7 +131,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { * * @see https://trpc.io/docs/procedures */ -export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); +export const protectedProcedure = baseProcedure.use(enforceUserIsAuthed); /** * Procedure that requires a specific permission diff --git a/packages/common/src/url.ts b/packages/common/src/url.ts index 7550def44..f13cc1efc 100644 --- a/packages/common/src/url.ts +++ b/packages/common/src/url.ts @@ -1,7 +1,11 @@ import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; export const removeTrailingSlash = (path: string) => { - return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path; + let end = path.length; + while (end > 0 && path.charAt(end - 1) === "/") { + end--; + } + return path.slice(0, end); }; export const extractBaseUrlFromHeaders = ( diff --git a/packages/db/migrations/mysql/0039_add_user_onboarding_tour_status.sql b/packages/db/migrations/mysql/0039_add_user_onboarding_tour_status.sql new file mode 100644 index 000000000..356540dc7 --- /dev/null +++ b/packages/db/migrations/mysql/0039_add_user_onboarding_tour_status.sql @@ -0,0 +1,2 @@ +ALTER TABLE `user` ADD `completed_manage_tour` boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `user` ADD `completed_board_tour` boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/mysql/meta/0039_snapshot.json b/packages/db/migrations/mysql/meta/0039_snapshot.json new file mode 100644 index 000000000..6630bd286 --- /dev/null +++ b/packages/db/migrations/mysql/meta/0039_snapshot.json @@ -0,0 +1,2120 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "e7c06822-4b94-49ea-b685-acb08ee6b63e", + "prevId": "ddg-bangs-mysql-0038", + "tables": { + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": ["provider", "provider_account_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "apiKey_id": { + "name": "apiKey_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "app": { + "name": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_id": { + "name": "app_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "boardGroupPermission": { + "name": "boardGroupPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "name": "boardGroupPermission_board_id_group_id_permission_pk", + "columns": ["board_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "boardUserPermission": { + "name": "boardUserPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "name": "boardUserPermission_board_id_user_id_permission_pk", + "columns": ["board_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "board": { + "name": "board", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('fixed')" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('no-repeat')" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('cover')" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('#fa5252')" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('#fd7e14')" + }, + "opacity": { + "name": "opacity", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('lg')" + }, + "disable_status": { + "name": "disable_status", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "board_id": { + "name": "board_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "board_name_unique": { + "name": "board_name_unique", + "columns": ["name"] + } + }, + "checkConstraint": {} + }, + "cron_job_configuration": { + "name": "cron_job_configuration", + "columns": { + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "cron_job_configuration_name": { + "name": "cron_job_configuration_name", + "columns": ["name"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "groupMember": { + "name": "groupMember", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "name": "groupMember_group_id_user_id_pk", + "columns": ["group_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "groupPermission": { + "name": "groupPermission", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_id": { + "name": "group_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "group_name_unique": { + "name": "group_name_unique", + "columns": ["name"] + } + }, + "checkConstraint": {} + }, + "iconRepository": { + "name": "iconRepository", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "iconRepository_id": { + "name": "iconRepository_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "icon": { + "name": "icon", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["icon_repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "icon_id": { + "name": "icon_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_group_permission__pk": { + "name": "integration_group_permission__pk", + "columns": ["integration_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration_item": { + "name": "integration_item", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "name": "integration_item_item_id_integration_id_pk", + "columns": ["item_id", "integration_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationSecret": { + "name": "integrationSecret", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": ["kind"], + "isUnique": false + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "name": "integrationSecret_integration_id_kind_pk", + "columns": ["integration_id", "kind"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationUserPermission": { + "name": "integrationUserPermission", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "name": "integrationUserPermission_integration_id_user_id_permission_pk", + "columns": ["integration_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": ["kind"], + "isUnique": false + } + }, + "foreignKeys": { + "integration_app_id_app_id_fk": { + "name": "integration_app_id_app_id_fk", + "tableFrom": "integration", + "tableTo": "app", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_id": { + "name": "integration_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "invite_id": { + "name": "invite_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "invite_token_unique": { + "name": "invite_token_unique", + "columns": ["token"] + } + }, + "checkConstraint": {} + }, + "item_layout": { + "name": "item_layout", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "name": "item_layout_item_id_section_id_layout_id_pk", + "columns": ["item_id", "section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_id": { + "name": "item_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "layout": { + "name": "layout", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "column_count": { + "name": "column_count", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "breakpoint": { + "name": "breakpoint", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "layout_id": { + "name": "layout_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "media": { + "name": "media", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "LONGBLOB", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_id": { + "name": "media_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "onboarding": { + "name": "onboarding", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_step": { + "name": "previous_step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onboarding_id": { + "name": "onboarding_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "search_engine": { + "name": "search_engine", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "short": { + "name": "short", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "search_engine_id": { + "name": "search_engine_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "columns": ["short"] + } + }, + "checkConstraint": {} + }, + "section_collapse_state": { + "name": "section_collapse_state", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collapsed": { + "name": "collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "name": "section_collapse_state_user_id_section_id_pk", + "columns": ["user_id", "section_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "section_layout": { + "name": "section_layout", + "columns": { + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["parent_section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "name": "section_layout_section_id_layout_id_pk", + "columns": ["section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "section": { + "name": "section", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_id": { + "name": "section_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "serverSetting": { + "name": "serverSetting", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "serverSetting_setting_key": { + "name": "serverSetting_setting_key", + "columns": ["setting_key"] + } + }, + "uniqueConstraints": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "columns": ["setting_key"] + } + }, + "checkConstraint": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_session_token": { + "name": "session_session_token", + "columns": ["session_token"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ddg_bangs": { + "name": "ddg_bangs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "color_scheme": { + "name": "color_scheme", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "completed_manage_tour": { + "name": "completed_manage_tour", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "completed_board_tour": { + "name": "completed_board_tour", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": ["default_search_engine_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_id": { + "name": "user_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": ["identifier", "token"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "columns": { + "hostname": { + "name": "hostname", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbprint": { + "name": "thumbprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "name": "trusted_certificate_hostname_hostname_thumbprint_pk", + "columns": ["hostname", "thumbprint"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/db/migrations/mysql/meta/_journal.json b/packages/db/migrations/mysql/meta/_journal.json index 421c81699..f420759ec 100644 --- a/packages/db/migrations/mysql/meta/_journal.json +++ b/packages/db/migrations/mysql/meta/_journal.json @@ -274,6 +274,13 @@ "when": 1775400000000, "tag": "0038_add_user_ddg_bangs", "breakpoints": true + }, + { + "idx": 39, + "version": "5", + "when": 1779199104477, + "tag": "0039_add_user_onboarding_tour_status", + "breakpoints": true } ] } diff --git a/packages/db/migrations/postgresql/0005_add_user_onboarding_tour_status.sql b/packages/db/migrations/postgresql/0005_add_user_onboarding_tour_status.sql new file mode 100644 index 000000000..dda51181e --- /dev/null +++ b/packages/db/migrations/postgresql/0005_add_user_onboarding_tour_status.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" ADD COLUMN "completed_manage_tour" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "completed_board_tour" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/postgresql/meta/0005_snapshot.json b/packages/db/migrations/postgresql/meta/0005_snapshot.json new file mode 100644 index 000000000..759659315 --- /dev/null +++ b/packages/db/migrations/postgresql/meta/0005_snapshot.json @@ -0,0 +1,2016 @@ +{ + "id": "2869b3b7-af54-4fbc-a953-766387ec2e3d", + "prevId": "ddg-bangs-pg-0004", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": ["provider", "provider_account_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apiKey": { + "name": "apiKey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boardGroupPermission": { + "name": "boardGroupPermission", + "schema": "", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "name": "boardGroupPermission_board_id_group_id_permission_pk", + "columns": ["board_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boardUserPermission": { + "name": "boardUserPermission", + "schema": "", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "name": "boardUserPermission_board_id_user_id_permission_pk", + "columns": ["board_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board": { + "name": "board", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'no-repeat'" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cover'" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#fa5252'" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#fd7e14'" + }, + "opacity": { + "name": "opacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'lg'" + }, + "disable_status": { + "name": "disable_status", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "board_name_unique": { + "name": "board_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cron_job_configuration": { + "name": "cron_job_configuration", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.groupMember": { + "name": "groupMember", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "name": "groupMember_group_id_user_id_pk", + "columns": ["group_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.groupPermission": { + "name": "groupPermission", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group": { + "name": "group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_name_unique": { + "name": "group_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.iconRepository": { + "name": "iconRepository", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.icon": { + "name": "icon", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["icon_repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "schema": "", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_group_permission__pk": { + "name": "integration_group_permission__pk", + "columns": ["integration_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_item": { + "name": "integration_item", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "name": "integration_item_item_id_integration_id_pk", + "columns": ["item_id", "integration_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrationSecret": { + "name": "integrationSecret", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "name": "integrationSecret_integration_id_kind_pk", + "columns": ["integration_id", "kind"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrationUserPermission": { + "name": "integrationUserPermission", + "schema": "", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "name": "integrationUserPermission_integration_id_user_id_permission_pk", + "columns": ["integration_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_app_id_app_id_fk": { + "name": "integration_app_id_app_id_fk", + "tableFrom": "integration", + "tableTo": "app", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "expiration_date": { + "name": "expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invite_token_unique": { + "name": "invite_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_layout": { + "name": "item_layout", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "name": "item_layout_item_id_section_id_layout_id_pk", + "columns": ["item_id", "section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item": { + "name": "item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.layout": { + "name": "layout", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "column_count": { + "name": "column_count", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "breakpoint": { + "name": "breakpoint", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding": { + "name": "onboarding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "step": { + "name": "step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "previous_step": { + "name": "previous_step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_engine": { + "name": "search_engine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "nullsNotDistinct": false, + "columns": ["short"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section_collapse_state": { + "name": "section_collapse_state", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "collapsed": { + "name": "collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "name": "section_collapse_state_user_id_section_id_pk", + "columns": ["user_id", "section_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section_layout": { + "name": "section_layout", + "schema": "", + "columns": { + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["parent_section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "name": "section_layout_section_id_layout_id_pk", + "columns": ["section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section": { + "name": "section", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.serverSetting": { + "name": "serverSetting", + "schema": "", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "nullsNotDistinct": false, + "columns": ["setting_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "varchar(512)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ddg_bangs": { + "name": "ddg_bangs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "color_scheme": { + "name": "color_scheme", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "completed_manage_tour": { + "name": "completed_manage_tour", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "completed_board_tour": { + "name": "completed_board_tour", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": ["default_search_engine_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": ["identifier", "token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "schema": "", + "columns": { + "hostname": { + "name": "hostname", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "thumbprint": { + "name": "thumbprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "name": "trusted_certificate_hostname_hostname_thumbprint_pk", + "columns": ["hostname", "thumbprint"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/postgresql/meta/_journal.json b/packages/db/migrations/postgresql/meta/_journal.json index d0a3f7683..b6ecf835f 100644 --- a/packages/db/migrations/postgresql/meta/_journal.json +++ b/packages/db/migrations/postgresql/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1775400000000, "tag": "0004_add_user_ddg_bangs", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1779199097716, + "tag": "0005_add_user_onboarding_tour_status", + "breakpoints": true } ] } diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index a2dec94af..a2f19e59f 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -1,29 +1,38 @@ +import SuperJSON from "superjson"; + import { createId, objectKeys } from "@homarr/common"; import { createDocumentationLink, credentialsAdminGroup, + defaultBookmarkApps, everyoneGroup, getIntegrationDefaultUrl, getIntegrationName, integrationDefs, integrationKinds, } from "@homarr/definitions"; +import type { WidgetKind } from "@homarr/definitions"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; import type { Database } from ".."; import { eq } from ".."; -import { getMaxGroupPositionAsync } from "../queries"; +import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries"; import { getServerSettingByKeyAsync, insertServerSettingByKeyAsync, updateServerSettingByKeyAsync, } from "../queries/server-setting"; + import { + apps, boards, groupMembers, groupPermissions, groups, + integrationItems, integrations, + itemLayouts, + items, layouts, onboarding, searchEngines, @@ -32,14 +41,26 @@ import { } from "../schema"; import type { Integration } from "../schema"; +const isTruthyEnv = (value: string | undefined) => + ["1", "yes", "t", "true"].includes((value ?? "").toLowerCase()); + export const seedDataAsync = async (db: Database) => { + if (isTruthyEnv(process.env.UNSAFE_ENABLE_MOCK_INTEGRATION)) { + console.warn( + "UNSAFE_ENABLE_MOCK_INTEGRATION is enabled: mock integration is available in the UI. Disable by setting UNSAFE_ENABLE_MOCK_INTEGRATION=false.", + ); + } + await seedEveryoneGroupAsync(db); await seedOnboardingAsync(db); await seedServerSettingsAsync(db); await seedDefaultSearchEnginesAsync(db); await seedDefaultIntegrationsAsync(db); + await seedDefaultAppsAsync(db); + await seedDefaultBoardAsync(db); + await seedBoardWidgetsAsync(db); - if (process.env.DEMO_MODE === "true") { + if (isTruthyEnv(process.env.DEMO_MODE)) { await seedDemoUserAsync(db); } }; @@ -209,6 +230,192 @@ const seedDefaultIntegrationsAsync = async (db: Database) => { console.log(`Created ${createdCount} default integrations through seeding process`); }; +const seedDefaultAppsAsync = async (db: Database) => { + const existingApps = await db.$count(apps); + if (existingApps > 0) { + console.log("Skipping seeding of default apps as some already exist"); + return; + } + + for (const app of defaultBookmarkApps) { + await db.insert(apps).values({ + id: createId(), + name: app.name, + iconUrl: app.iconUrl, + href: app.href, + }); + } + console.log(`Created ${defaultBookmarkApps.length} default apps through seeding process`); +}; + +const seedDefaultBoardAsync = async (db: Database) => { + const existingBoard = await db.query.boards.findFirst(); + + if (existingBoard) { + console.log("Skipping seeding of default board as one already exists"); + return; + } + + const boardId = createId(); + await db.insert(boards).values({ + id: boardId, + name: "dashboard", + isPublic: false, + }); + await db.insert(sections).values({ + id: createId(), + kind: "empty", + xOffset: 0, + yOffset: 0, + boardId, + }); + await db.insert(layouts).values({ + id: createId(), + name: "Base", + columnCount: 10, + breakpoint: 0, + boardId, + }); + + const everyoneGroupRow = await db.query.groups.findFirst({ + where: eq(groups.name, everyoneGroup), + }); + if (everyoneGroupRow) { + await db.update(groups).set({ homeBoardId: boardId }).where(eq(groups.id, everyoneGroupRow.id)); + console.log("Set default board as home board for everyone group"); + } + + console.log("Created default board 'dashboard' through seed"); +}; + +interface DemoWidget { + kind: WidgetKind; + width: number; + height: number; + needsIntegration: boolean; + options?: Record; +} + +const demoApps = [ + { + name: "Sonarr", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg", + href: "https://sonarr.tv", + }, + { + name: "Radarr", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg", + href: "https://radarr.video", + }, + { + name: "Plex", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg", + href: "https://plex.tv", + }, + { + name: "Jellyfin", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg", + href: "https://jellyfin.org", + }, + { + name: "Proxmox", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg", + href: "https://proxmox.com", + }, + { + name: "Pi-hole", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg", + href: "https://pi-hole.net", + }, + { + name: "Grafana", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/grafana.svg", + href: "https://grafana.com", + }, + { + name: "Portainer", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/portainer.svg", + href: "https://portainer.io", + }, + { + name: "Home Assistant", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg", + href: "https://home-assistant.io", + }, + { + name: "Nextcloud", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg", + href: "https://nextcloud.com", + }, + { + name: "qBittorrent", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg", + href: "https://qbittorrent.org", + }, + { + name: "Overseerr", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg", + href: "https://overseerr.dev", + }, +] as const; + +const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [ + // Row 1: calendar + downloads + clock = 12 + { kind: "calendar", width: 5, height: 3, needsIntegration: true }, + { kind: "downloads", width: 5, height: 3, needsIntegration: true }, + { kind: "clock", width: 2, height: 1, needsIntegration: false }, + // Row 2: healthMonitoring + bookmarks = 12 + { kind: "healthMonitoring", width: 6, height: 3, needsIntegration: true }, + { + kind: "bookmarks", + width: 6, + height: 3, + needsIntegration: false, + options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true }, + }, + // Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12 + { kind: "mediaServer", width: 3, height: 2, needsIntegration: true }, + { kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true }, + { kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true }, + // Row 4: dnsHoleControls + mediaReleases + notifications = 12 + { kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true }, + { kind: "mediaReleases", width: 4, height: 2, needsIntegration: true }, + { kind: "notifications", width: 6, height: 2, needsIntegration: true }, + // Row 5: requestList + requestStats + indexerManager = 12 + { kind: "mediaRequests-requestList", width: 4, height: 3, needsIntegration: true }, + { kind: "mediaRequests-requestStats", width: 4, height: 2, needsIntegration: true }, + { kind: "indexerManager", width: 4, height: 2, needsIntegration: true }, + // Row 6: networkControllerSummary + networkControllerStatus + rssFeed = 12 + { kind: "networkControllerSummary", width: 3, height: 2, needsIntegration: true }, + { kind: "networkControllerStatus", width: 3, height: 2, needsIntegration: true }, + { + kind: "rssFeed", + width: 6, + height: 2, + needsIntegration: false, + options: { + feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"], + maximumAmountPosts: 20, + textLinesClamp: 2, + hideDescription: false, + }, + }, + // Row 7-8: app widgets (2x1 each, 6 per row = 12) + ...appIds.map( + (appId): DemoWidget => ({ + kind: "app", + width: 2, + height: 1, + needsIntegration: false, + options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false }, + }), + ), + // Row 9: notebook + dockerContainers + weather = 12 + { kind: "notebook", width: 4, height: 4, needsIntegration: false }, + { kind: "dockerContainers", width: 6, height: 2, needsIntegration: false }, + { kind: "weather", width: 2, height: 1, needsIntegration: false }, +]; + const seedDemoUserAsync = async (db: Database) => { const existingUser = await db.query.users.findFirst({ where: eq(users.name, "demo"), @@ -225,7 +432,7 @@ const seedDemoUserAsync = async (db: Database) => { id: userId, name: "demo", email: "demo@example.com", - password: "$2b$10$c5AaQb0oj32/bui5Z55PyOb7pY.iEkXYqgdD8MjS9LjSjLMeOkZu2", + password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u", }); const maxPosition = await getMaxGroupPositionAsync(db); @@ -250,6 +457,27 @@ const seedDemoUserAsync = async (db: Database) => { previousStep: "settings", }); + const integrationId = createId(); + await db.insert(integrations).values({ + id: integrationId, + name: "Demo Integration", + url: "https://demo.homarr.dev", + kind: "mock", + appId: null, + }); + + const appIds: string[] = []; + for (const app of demoApps) { + const appId = createId(); + appIds.push(appId); + await db.insert(apps).values({ + id: appId, + name: app.name, + iconUrl: app.iconUrl, + href: app.href, + }); + } + const boardId = createId(); await db.insert(boards).values({ id: boardId, @@ -257,23 +485,93 @@ const seedDemoUserAsync = async (db: Database) => { isPublic: false, creatorId: userId, }); + + const sectionId = createId(); await db.insert(sections).values({ - id: createId(), + id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId, }); + + const layoutId = createId(); await db.insert(layouts).values({ - id: createId(), + id: layoutId, name: "Base", columnCount: 12, breakpoint: 0, boardId, }); + + const demoWidgets = buildDemoWidgets(appIds); + let xOffset = 0; + let yOffset = 0; + let rowMaxHeight = 0; + for (const widget of demoWidgets) { + if (xOffset + widget.width > 12) { + xOffset = 0; + yOffset += rowMaxHeight; + rowMaxHeight = 0; + } + rowMaxHeight = Math.max(rowMaxHeight, widget.height); + const itemId = createId(); + await db.insert(items).values({ + id: itemId, + boardId, + kind: widget.kind, + ...(widget.options ? { options: SuperJSON.stringify(widget.options) } : {}), + }); + await db.insert(itemLayouts).values({ + itemId, + sectionId, + layoutId, + xOffset, + yOffset, + width: widget.width, + height: widget.height, + }); + if (widget.needsIntegration) { + await db.insert(integrationItems).values({ + itemId, + integrationId, + }); + } + xOffset += widget.width; + } + await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId)); console.log( - "Your instance has enabled demo mode. Homarr therefore created example data for your demo. You can disable demo mode anytime by setting the env 'DEMO_MODE' to 'false' or unset it.", + "Demo mode enabled: created demo user, mock integration, and sample board with widgets. Disable by setting DEMO_MODE=false.", ); }; + +const seedBoardWidgetsAsync = async (db: Database) => { + const existingItems = await db.$count(items); + if (existingItems > 0) { + console.log("Skipping seeding of board widgets as some already exist"); + return; + } + + const board = await db.query.boards.findFirst({ + with: { sections: true, layouts: true }, + }); + if (!board) return; + + const section = board.sections.find((sec) => sec.kind === "empty"); + const layout = board.layouts[0]; + if (!section || !layout) return; + + const allIntegrations = await db.query.integrations.findMany(); + const allApps = await db.query.apps.findMany(); + + const count = await placeAllWidgetsAsync( + db, + { boardId: board.id, sectionId: section.id, layoutId: layout.id, columnCount: layout.columnCount }, + allIntegrations, + allApps, + ); + + console.log(`Placed ${count} widgets on board`); +}; diff --git a/packages/db/migrations/sqlite/0037_add_user_onboarding_tour_status.sql b/packages/db/migrations/sqlite/0037_add_user_onboarding_tour_status.sql new file mode 100644 index 000000000..292ce4ece --- /dev/null +++ b/packages/db/migrations/sqlite/0037_add_user_onboarding_tour_status.sql @@ -0,0 +1,2 @@ +ALTER TABLE `user` ADD `completed_manage_tour` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `user` ADD `completed_board_tour` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/sqlite/meta/0037_snapshot.json b/packages/db/migrations/sqlite/meta/0037_snapshot.json new file mode 100644 index 000000000..74827789c --- /dev/null +++ b/packages/db/migrations/sqlite/meta/0037_snapshot.json @@ -0,0 +1,2035 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f30b6535-b4d5-4501-8370-7a7605e28bbd", + "prevId": "ddg-bangs-sqlite-0036", + "tables": { + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "columns": ["provider", "provider_account_id"], + "name": "account_provider_provider_account_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app": { + "name": "app", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "boardGroupPermission": { + "name": "boardGroupPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "columns": ["board_id", "group_id", "permission"], + "name": "boardGroupPermission_board_id_group_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "boardUserPermission": { + "name": "boardUserPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "columns": ["board_id", "user_id", "permission"], + "name": "boardUserPermission_board_id_user_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "board": { + "name": "board", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fixed'" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'no-repeat'" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cover'" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#fa5252'" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#fd7e14'" + }, + "opacity": { + "name": "opacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lg'" + }, + "disable_status": { + "name": "disable_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "board_name_unique": { + "name": "board_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cron_job_configuration": { + "name": "cron_job_configuration", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groupMember": { + "name": "groupMember", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "columns": ["group_id", "user_id"], + "name": "groupMember_group_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groupPermission": { + "name": "groupPermission", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "group_name_unique": { + "name": "group_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "iconRepository": { + "name": "iconRepository", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "icon": { + "name": "icon", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["icon_repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationGroupPermissions_integration_id_group_id_permission_pk": { + "columns": ["integration_id", "group_id", "permission"], + "name": "integrationGroupPermissions_integration_id_group_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_item": { + "name": "integration_item", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "columns": ["item_id", "integration_id"], + "name": "integration_item_item_id_integration_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationSecret": { + "name": "integrationSecret", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": ["kind"], + "isUnique": false + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "columns": ["integration_id", "kind"], + "name": "integrationSecret_integration_id_kind_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationUserPermission": { + "name": "integrationUserPermission", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "columns": ["integration_id", "user_id", "permission"], + "name": "integrationUserPermission_integration_id_user_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": ["kind"], + "isUnique": false + } + }, + "foreignKeys": { + "integration_app_id_app_id_fk": { + "name": "integration_app_id_app_id_fk", + "tableFrom": "integration", + "tableTo": "app", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invite_token_unique": { + "name": "invite_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "item_layout": { + "name": "item_layout", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "columns": ["item_id", "section_id", "layout_id"], + "name": "item_layout_item_id_section_id_layout_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "layout": { + "name": "layout", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "column_count": { + "name": "column_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "breakpoint": { + "name": "breakpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "onboarding": { + "name": "onboarding", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_step": { + "name": "previous_step", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "search_engine": { + "name": "search_engine", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "columns": ["short"], + "isUnique": true + } + }, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section_collapse_state": { + "name": "section_collapse_state", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collapsed": { + "name": "collapsed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "columns": ["user_id", "section_id"], + "name": "section_collapse_state_user_id_section_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section_layout": { + "name": "section_layout", + "columns": { + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["parent_section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "columns": ["section_id", "layout_id"], + "name": "section_layout_section_id_layout_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section": { + "name": "section", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serverSetting": { + "name": "serverSetting", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "columns": ["setting_key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "columns": { + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbprint": { + "name": "thumbprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "columns": ["hostname", "thumbprint"], + "name": "trusted_certificate_hostname_hostname_thumbprint_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "ddg_bangs": { + "name": "ddg_bangs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "color_scheme": { + "name": "color_scheme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "completed_manage_tour": { + "name": "completed_manage_tour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "completed_board_tour": { + "name": "completed_board_tour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": ["default_search_engine_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": ["identifier", "token"], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/db/migrations/sqlite/meta/_journal.json b/packages/db/migrations/sqlite/meta/_journal.json index 5543bbde0..2f4866c79 100644 --- a/packages/db/migrations/sqlite/meta/_journal.json +++ b/packages/db/migrations/sqlite/meta/_journal.json @@ -260,6 +260,13 @@ "when": 1775400000000, "tag": "0036_add_user_ddg_bangs", "breakpoints": true + }, + { + "idx": 37, + "version": "6", + "when": 1779199046805, + "tag": "0037_add_user_onboarding_tour_status", + "breakpoints": true } ] } diff --git a/packages/db/queries/index.ts b/packages/db/queries/index.ts index 13e90d6eb..368d7eaa2 100644 --- a/packages/db/queries/index.ts +++ b/packages/db/queries/index.ts @@ -1,3 +1,4 @@ export * from "./item"; export * from "./server-setting"; export * from "./group"; +export * from "./widget-placement"; diff --git a/packages/db/queries/widget-placement.ts b/packages/db/queries/widget-placement.ts new file mode 100644 index 000000000..1d0b529f9 --- /dev/null +++ b/packages/db/queries/widget-placement.ts @@ -0,0 +1,148 @@ +import superjson from "superjson"; + +import { createId } from "@homarr/common"; +import { + defaultBookmarkApps, + defaultWidgetConfigs, + emptySuperJSON, + getWidgetKindsForIntegration, +} from "@homarr/definitions"; +import type { WidgetKind } from "@homarr/definitions"; + +import type { Database } from ".."; +import { integrationItems, itemLayouts, items } from "../schema"; +import type { Integration } from "../schema"; + +const widgetConfigMap = new Map(defaultWidgetConfigs.map((config) => [config.kind, config])); + +interface BoardTarget { + boardId: string; + sectionId: string; + layoutId: string; + columnCount: number; +} + +interface PlacementContext extends BoardTarget { + xOffset: number; + yOffset: number; + rowMaxHeight: number; +} + +const placeWidgetAsync = async ( + db: Database, + ctx: PlacementContext, + kind: WidgetKind, + linkedIntegrationIds: string[], + options?: string, + size?: { width: number; height: number }, +) => { + const itemWidth = size?.width ?? 2; + const itemHeight = size?.height ?? 2; + + if (ctx.xOffset + itemWidth > ctx.columnCount) { + ctx.xOffset = 0; + ctx.yOffset += ctx.rowMaxHeight; + ctx.rowMaxHeight = 0; + } + + const itemId = createId(); + await db.insert(items).values({ + id: itemId, + boardId: ctx.boardId, + kind, + options: options ?? emptySuperJSON, + advancedOptions: emptySuperJSON, + }); + + await db.insert(itemLayouts).values({ + itemId, + sectionId: ctx.sectionId, + layoutId: ctx.layoutId, + xOffset: ctx.xOffset, + yOffset: ctx.yOffset, + width: itemWidth, + height: itemHeight, + }); + + for (const integrationId of linkedIntegrationIds) { + await db.insert(integrationItems).values({ itemId, integrationId }); + } + + ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight); + ctx.xOffset += itemWidth; +}; + +interface App { + id: string; + name: string; +} + +export const placeAllWidgetsAsync = async ( + db: Database, + target: BoardTarget, + allIntegrations: Integration[], + allApps: App[], +) => { + const ctx: PlacementContext = { + ...target, + xOffset: 0, + yOffset: 0, + rowMaxHeight: 0, + }; + + const placedWidgets = new Set(); + + for (const integration of allIntegrations) { + for (const widgetKind of getWidgetKindsForIntegration(integration.kind)) { + if (placedWidgets.has(widgetKind)) continue; + const config = widgetConfigMap.get(widgetKind); + if (config?.skip) continue; + placedWidgets.add(widgetKind); + + const matchingIds = allIntegrations + .filter((row) => getWidgetKindsForIntegration(row.kind).includes(widgetKind)) + .map((row) => row.id); + + const options = config?.options ? superjson.stringify(config.options) : undefined; + await placeWidgetAsync( + db, + ctx, + widgetKind, + matchingIds, + options, + config && { width: config.width, height: config.height }, + ); + } + } + + for (const app of allApps) { + await placeWidgetAsync( + db, + ctx, + "app", + [], + superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }), + { width: 1, height: 1 }, + ); + } + + const bookmarkAppNames = new Set(defaultBookmarkApps.map((bookmark) => bookmark.name)); + const bookmarkAppIds = allApps.filter((app) => bookmarkAppNames.has(app.name)).map((app) => app.id); + + for (const config of defaultWidgetConfigs) { + if (config.skip || placedWidgets.has(config.kind)) continue; + placedWidgets.add(config.kind); + + let options = config.options ? { ...config.options } : undefined; + if (config.kind === "bookmarks" && bookmarkAppIds.length > 0) { + options = { ...options, items: bookmarkAppIds }; + } + + await placeWidgetAsync(db, ctx, config.kind, [], options ? superjson.stringify(options) : undefined, { + width: config.width, + height: config.height, + }); + } + + return placedWidgets.size + allApps.length; +}; diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index 84c5325ed..7e2897c46 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -80,6 +80,8 @@ export const users = mysqlTable("user", { colorScheme: varchar({ length: 5 }).$type().default("dark").notNull(), firstDayOfWeek: tinyint().$type().default(1).notNull(), // Defaults to Monday pingIconsEnabled: boolean().default(false).notNull(), + completedManageTour: boolean().default(false).notNull(), + completedBoardTour: boolean().default(false).notNull(), }); export const accounts = mysqlTable( diff --git a/packages/db/schema/postgresql.ts b/packages/db/schema/postgresql.ts index 412916013..8ea445325 100644 --- a/packages/db/schema/postgresql.ts +++ b/packages/db/schema/postgresql.ts @@ -79,6 +79,8 @@ export const users = pgTable("user", { colorScheme: varchar({ length: 5 }).$type().default("dark").notNull(), firstDayOfWeek: smallint().$type().default(1).notNull(), // Defaults to Monday pingIconsEnabled: boolean().default(false).notNull(), + completedManageTour: boolean().default(false).notNull(), + completedBoardTour: boolean().default(false).notNull(), }); export const accounts = pgTable( diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index 50671db85..b4fe5bbe0 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -62,6 +62,8 @@ export const users = sqliteTable("user", { colorScheme: text().$type().default("dark").notNull(), firstDayOfWeek: int().$type().default(1).notNull(), // Defaults to Monday pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(), + completedManageTour: int({ mode: "boolean" }).default(false).notNull(), + completedBoardTour: int({ mode: "boolean" }).default(false).notNull(), }); export const accounts = sqliteTable( diff --git a/packages/definitions/src/docker-integration-match.ts b/packages/definitions/src/docker-integration-match.ts new file mode 100644 index 000000000..c85715808 --- /dev/null +++ b/packages/definitions/src/docker-integration-match.ts @@ -0,0 +1,90 @@ +import { objectKeys } from "@homarr/common"; + +import { integrationDefs } from "./integration"; +import type { IntegrationKind } from "./integration"; + +export const extractContainerImageName = (image: string): string => image.split("/").at(-1)?.split(":").at(0) ?? ""; + +const extractIconSlug = (iconUrl: string): string => { + const filename = iconUrl.split("/").pop() ?? ""; + return filename.replace(/\.(svg|png)$/, "").toLowerCase(); +}; + +export const integrationIconSlugs: Record = Object.fromEntries( + objectKeys(integrationDefs).map((kind) => [kind, extractIconSlug(integrationDefs[kind].iconUrl)]), +) as Record; + +const integrationAliases: Partial> = { + piHole: ["pihole", "pi-hole"], + adGuardHome: ["adguardhome", "adguard-home", "adguard"], + homeAssistant: ["homeassistant", "home-assistant", "hass"], + openmediavault: ["omv"], + qBittorrent: ["qbittorrent"], + sabNzbd: ["sabnzbd"], + nzbGet: ["nzbget"], + unifiController: ["unifi", "unifi-controller"], + jellyseerr: ["jellyseerr"], + overseerr: ["overseerr"], + dashDot: ["dashdot", "dash-dot", "dash."], + speedtestTracker: ["speedtest-tracker"], + searchCh: ["search-ch"], + gitHubContainerRegistry: ["ghcr"], + linuxServerIO: ["linuxserver"], + coolify: ["coolify"], + truenas: ["truenas"], +}; + +export const matchIntegrationKind = (search: string): IntegrationKind | null => { + const normalized = search.toLowerCase().trim(); + if (!normalized) return null; + + for (const kind of objectKeys(integrationDefs)) { + if (kind.toLowerCase() === normalized) return kind; + if (integrationDefs[kind].name.toLowerCase() === normalized) return kind; + } + + for (const kind of objectKeys(integrationDefs)) { + if (integrationIconSlugs[kind] === normalized) return kind; + } + + for (const [kind, aliases] of Object.entries(integrationAliases) as [IntegrationKind, readonly string[]][]) { + if (aliases.some((alias) => alias === normalized)) return kind; + } + + for (const kind of objectKeys(integrationDefs)) { + if (normalized.includes(kind.toLowerCase())) return kind; + if (normalized.includes(integrationIconSlugs[kind])) return kind; + } + + return null; +}; + +const notDockerDiscoverable = new Set([ + "dockerHub", + "github", + "gitlab", + "npm", + "codeberg", + "linuxServerIO", + "gitHubContainerRegistry", + "quay", + "ical", + "searchCh", + "mock", +]); + +interface ContainerMatchInput { + image: string; + name: string; +} + +export const matchIntegrationKindFromContainer = (container: ContainerMatchInput): IntegrationKind | null => { + const imageName = extractContainerImageName(container.image); + const fromImage = matchIntegrationKind(imageName); + if (fromImage && !notDockerDiscoverable.has(fromImage)) return fromImage; + + const fromName = matchIntegrationKind(container.name); + if (fromName && !notDockerDiscoverable.has(fromName)) return fromName; + + return null; +}; diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index b97a34937..348bbf78d 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -157,6 +157,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/anchor" | "/docs/integrations/aria2" | "/docs/integrations/codeberg" + | "/docs/integrations/coolify" | "/docs/integrations/dash-dot" | "/docs/integrations/deluge" | "/docs/integrations/docker-hub" @@ -181,6 +182,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/open-media-vault" | "/docs/integrations/opnsense" | "/docs/integrations/overseerr" + | "/docs/integrations/peanut" | "/docs/integrations/pi-hole" | "/docs/integrations/plex" | "/docs/integrations/prowlarr" @@ -217,6 +219,7 @@ export type HomarrDocumentationPath = | "/docs/widgets/bookmarks" | "/docs/widgets/calendar" | "/docs/widgets/clock" + | "/docs/widgets/coolify" | "/docs/widgets/dns-hole-controls" | "/docs/widgets/dns-hole-summary" | "/docs/widgets/docker-containers" @@ -247,6 +250,7 @@ export type HomarrDocumentationPath = | "/docs/widgets/timetable" | "/docs/widgets/tracearr" | "/docs/widgets/umami" + | "/docs/widgets/ups" | "/docs/widgets/video" | "/docs/widgets/weather" | "" diff --git a/packages/definitions/src/index.ts b/packages/definitions/src/index.ts index 3eb639ba6..eb81e4d18 100644 --- a/packages/definitions/src/index.ts +++ b/packages/definitions/src/index.ts @@ -12,5 +12,8 @@ export * from "./docs"; export * from "./cookie"; export * from "./search-engine"; export * from "./onboarding"; +export * from "./widget-integration-map"; export * from "./emptysuperjson"; export * from "./hotkeys"; +export * from "./docker-integration-match"; +export * from "./integration-url-template"; diff --git a/packages/definitions/src/integration-url-template.ts b/packages/definitions/src/integration-url-template.ts new file mode 100644 index 000000000..bb8312c6e --- /dev/null +++ b/packages/definitions/src/integration-url-template.ts @@ -0,0 +1,41 @@ +import { removeTrailingSlash } from "@homarr/common"; + +import { integrationIconSlugs } from "./docker-integration-match"; +import type { IntegrationKind } from "./integration"; +import { getIntegrationDefaultPort } from "./integration"; + +export type UrlTemplateMode = "hostPort" | "subdomain"; + +const getSlugForKind = (kind: IntegrationKind): string => integrationIconSlugs[kind]; + +const buildUrl = (slug: string, host: string, mode: UrlTemplateMode, port?: number): string => { + const modeBuilders: Record string> = { + subdomain: () => `https://${slug}.${host}`, + hostPort: () => (port ? `http://${host}:${port}` : `http://${host}`), + }; + return modeBuilders[mode](); +}; + +export const buildIntegrationUrl = ( + kind: IntegrationKind, + baseHost: string, + mode: UrlTemplateMode, + dockerPort?: number, +): string => { + const host = removeTrailingSlash(baseHost.trim()); + if (!host) return ""; + const port = dockerPort ?? getIntegrationDefaultPort(kind); + return buildUrl(getSlugForKind(kind), host, mode, port ?? undefined); +}; + +export const buildAppUrl = ( + containerName: string, + baseHost: string, + mode: UrlTemplateMode, + dockerPort?: number, +): string => { + const host = removeTrailingSlash(baseHost.trim()); + if (!host) return ""; + const slug = containerName.toLowerCase().replace(/[^a-z0-9-]/g, "-"); + return buildUrl(slug, host, mode, dockerPort); +}; diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index f90f81a38..f43d821bf 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -1,4 +1,4 @@ -import { objectKeys } from "@homarr/common"; +import { objectKeys, removeTrailingSlash } from "@homarr/common"; import type { AtLeastOneOf } from "@homarr/common/types"; import { createDocumentationLink } from "./docs"; @@ -27,7 +27,9 @@ interface integrationDefinition { secretKinds: AtLeastOneOf; // at least one secret kind set is required category: AtLeastOneOf; documentationUrl: string | null; - defaultUrl?: string; // optional default URL for the integration + defaultUrl?: string; + defaultPort?: number; + apiKeySettingsPath?: string; } export const integrationDefs = { @@ -37,6 +39,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg", category: ["downloadClient", "usenet"], documentationUrl: createDocumentationLink("/docs/integrations/sabnzbd"), + defaultPort: 8080, + apiKeySettingsPath: "/config/general/", }, nzbGet: { name: "NZBGet", @@ -44,6 +48,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg", category: ["downloadClient", "usenet"], documentationUrl: createDocumentationLink("/docs/integrations/nzbget"), + defaultPort: 6789, }, deluge: { name: "Deluge", @@ -51,6 +56,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/deluge.svg", category: ["downloadClient", "torrent"], documentationUrl: createDocumentationLink("/docs/integrations/deluge"), + defaultPort: 8112, }, transmission: { name: "Transmission", @@ -58,6 +64,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg", category: ["downloadClient", "torrent"], documentationUrl: createDocumentationLink("/docs/integrations/transmission"), + defaultPort: 9091, }, qBittorrent: { name: "qBittorrent", @@ -65,6 +72,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg", category: ["downloadClient", "torrent"], documentationUrl: createDocumentationLink("/docs/integrations/q-bittorent"), + defaultPort: 8080, + apiKeySettingsPath: "/#/settings/webui", }, aria2: { name: "Aria2", @@ -72,6 +81,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg", category: ["downloadClient", "torrent", "miscellaneous"], documentationUrl: createDocumentationLink("/docs/integrations/aria2"), + defaultPort: 6800, }, slskd: { name: "Slskd", @@ -79,6 +89,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/slskd.svg", category: ["downloadClient", "miscellaneous"], documentationUrl: createDocumentationLink("/docs/integrations/slskd"), + defaultPort: 5030, }, sonarr: { name: "Sonarr", @@ -86,6 +97,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg", category: ["calendar"], documentationUrl: createDocumentationLink("/docs/integrations/sonarr"), + defaultPort: 8989, + apiKeySettingsPath: "/settings/general", }, radarr: { name: "Radarr", @@ -93,6 +106,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg", category: ["calendar"], documentationUrl: createDocumentationLink("/docs/integrations/radarr"), + defaultPort: 7878, + apiKeySettingsPath: "/settings/general", }, lidarr: { name: "Lidarr", @@ -100,6 +115,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/lidarr.svg", category: ["calendar"], documentationUrl: createDocumentationLink("/docs/integrations/lidarr"), + defaultPort: 8686, + apiKeySettingsPath: "/settings/general", }, readarr: { name: "Readarr", @@ -107,6 +124,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg", category: ["calendar"], documentationUrl: createDocumentationLink("/docs/integrations/readarr"), + defaultPort: 8787, + apiKeySettingsPath: "/settings/general", }, prowlarr: { name: "Prowlarr", @@ -114,6 +133,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg", category: ["indexerManager"], documentationUrl: createDocumentationLink("/docs/integrations/prowlarr"), + defaultPort: 9696, + apiKeySettingsPath: "/settings/general", }, jellyfin: { name: "Jellyfin", @@ -121,6 +142,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg", category: ["mediaService", "mediaRelease"], documentationUrl: createDocumentationLink("/docs/integrations/jellyfin"), + defaultPort: 8096, + apiKeySettingsPath: "/web/index.html#!/dashboard/keys", }, emby: { name: "Emby", @@ -128,6 +151,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg", category: ["mediaService", "mediaRelease"], documentationUrl: createDocumentationLink("/docs/integrations/emby"), + defaultPort: 8096, }, plex: { name: "Plex", @@ -135,6 +159,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg", category: ["mediaService", "mediaRelease"], documentationUrl: createDocumentationLink("/docs/integrations/plex"), + defaultPort: 32400, }, jellyseerr: { name: "Jellyseerr", @@ -142,6 +167,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg", category: ["mediaSearch", "mediaRequest", "search"], documentationUrl: createDocumentationLink("/docs/integrations/jellyseerr"), + defaultPort: 5055, + apiKeySettingsPath: "/settings", }, seerr: { name: "Seerr", @@ -149,6 +176,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg", category: ["mediaSearch", "mediaRequest", "search"], documentationUrl: createDocumentationLink("/docs/integrations/seerr"), + defaultPort: 5055, + apiKeySettingsPath: "/settings", }, overseerr: { name: "Overseerr", @@ -156,6 +185,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/overseerr.svg", category: ["mediaSearch", "mediaRequest", "search"], documentationUrl: createDocumentationLink("/docs/integrations/overseerr"), + defaultPort: 5055, + apiKeySettingsPath: "/settings", }, piHole: { name: "Pi-hole", @@ -163,6 +194,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg", category: ["dnsHole"], documentationUrl: createDocumentationLink("/docs/integrations/pi-hole"), + defaultPort: 80, }, adGuardHome: { name: "AdGuard Home", @@ -170,6 +202,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/adguard-home.svg", category: ["dnsHole"], documentationUrl: createDocumentationLink("/docs/integrations/adguard-home"), + defaultPort: 3000, }, homeAssistant: { name: "Home Assistant", @@ -177,6 +210,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg", category: ["smartHomeServer", "calendar"], documentationUrl: createDocumentationLink("/docs/integrations/home-assistant"), + defaultPort: 8123, }, openmediavault: { name: "OpenMediaVault", @@ -184,6 +218,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/openmediavault.svg", category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/open-media-vault"), + defaultPort: 80, }, dashDot: { name: "Dash.", @@ -198,6 +233,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg", category: ["healthMonitoring"], documentationUrl: null, + defaultPort: 61208, }, tdarr: { name: "Tdarr", @@ -205,6 +241,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png", category: ["mediaTranscoding"], documentationUrl: createDocumentationLink("/docs/integrations/tdarr"), + defaultPort: 8265, }, proxmox: { name: "Proxmox", @@ -212,6 +249,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg", category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/proxmox"), + defaultPort: 8006, }, nextcloud: { name: "Nextcloud", @@ -219,6 +257,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg", category: ["calendar", "notifications"], documentationUrl: createDocumentationLink("/docs/integrations/nextcloud"), + defaultPort: 443, }, unifiController: { name: "Unifi Controller", @@ -226,6 +265,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png", category: ["networkController"], documentationUrl: createDocumentationLink("/docs/integrations/unifi-controller"), + defaultPort: 8443, }, opnsense: { name: "OPNsense", @@ -233,6 +273,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg", category: ["firewall"], documentationUrl: createDocumentationLink("/docs/integrations/opnsense"), + defaultPort: 443, }, github: { name: "Github", @@ -304,6 +345,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg", category: ["notifications"], documentationUrl: createDocumentationLink("/docs/integrations/ntfy"), + defaultPort: 80, }, ical: { name: "iCal", @@ -318,6 +360,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg", category: ["notes"], documentationUrl: createDocumentationLink("/docs/integrations/anchor"), + defaultPort: 8080, }, truenas: { name: "TrueNAS", @@ -325,6 +368,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg", category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/truenas"), + defaultPort: 80, }, unraid: { name: "Unraid", @@ -332,20 +376,20 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg", category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/unraid"), + defaultPort: 80, }, coolify: { name: "Coolify", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg", category: ["healthMonitoring"], - // @ts-expect-error - docs page will be created when integration is merged documentationUrl: createDocumentationLink("/docs/integrations/coolify"), + defaultPort: 8000, }, searchCh: { name: "Search.ch", secretKinds: [[]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/search-ch.png", - defaultUrl: "https://search.ch", category: ["timetable"], documentationUrl: createDocumentationLink("/docs/integrations/search-ch"), }, @@ -355,6 +399,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/immich.svg", category: ["photoService"], documentationUrl: createDocumentationLink("/docs/integrations/immich"), + defaultPort: 2283, }, tracearr: { name: "Tracearr", @@ -362,6 +407,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg", category: ["mediaMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/tracearr"), + defaultPort: 7040, }, speedtestTracker: { name: "Speedtest Tracker", @@ -369,6 +415,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png", category: ["speedtest"], documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"), + defaultPort: 80, }, umami: { name: "Umami", @@ -377,6 +424,14 @@ export const integrationDefs = { category: ["analytics"], defaultUrl: "https://api.umami.is/v1", documentationUrl: createDocumentationLink("/docs/integrations/umami"), + defaultPort: 3000, + }, + peaNut: { + name: "PeaNUT", + secretKinds: [["username", "password"], []], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg", + category: ["ups"], + documentationUrl: null, }, // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) mock: { @@ -396,6 +451,7 @@ export const integrationDefs = { "networkController", "notifications", "smartHomeServer", + "ups", ], documentationUrl: null, }, @@ -418,6 +474,19 @@ export const getIntegrationDefaultUrl = (integration: IntegrationKind) => { return "defaultUrl" in definition ? definition.defaultUrl : undefined; }; +export const getIntegrationDefaultPort = (kind: IntegrationKind): number | undefined => { + const definition = integrationDefs[kind]; + return "defaultPort" in definition ? definition.defaultPort : undefined; +}; + +export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => { + const definition = integrationDefs[kind]; + if (!("apiKeySettingsPath" in definition)) return null; + const base = removeTrailingSlash(integrationUrl); + if (!base) return null; + return `${base}${definition.apiKeySettingsPath}`; +}; + /** * Get all integration kinds that share a category, typed only by the kinds belonging to the category * @param category Category to filter by, belonging to IntegrationCategory @@ -470,6 +539,7 @@ export const integrationCategories = [ "mediaMonitoring", "speedtest", "analytics", + "ups", ] as const; export type IntegrationCategory = (typeof integrationCategories)[number]; diff --git a/packages/definitions/src/onboarding.ts b/packages/definitions/src/onboarding.ts index d9a6ea03e..305a71f65 100644 --- a/packages/definitions/src/onboarding.ts +++ b/packages/definitions/src/onboarding.ts @@ -1,2 +1,2 @@ -export const onboardingSteps = ["start", "import", "user", "group", "settings", "finish"] as const; +export const onboardingSteps = ["start", "import", "user", "group", "settings", "integrations", "finish"] as const; export type OnboardingStep = (typeof onboardingSteps)[number]; diff --git a/packages/definitions/src/test/api-key-url.spec.ts b/packages/definitions/src/test/api-key-url.spec.ts new file mode 100644 index 000000000..21b7e0fe7 --- /dev/null +++ b/packages/definitions/src/test/api-key-url.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { getIntegrationApiKeyUrl } from "../integration"; + +describe("getIntegrationApiKeyUrl", () => { + it("builds URL for sonarr", () => { + expect(getIntegrationApiKeyUrl("http://192.168.1.10:8989", "sonarr")).toBe( + "http://192.168.1.10:8989/settings/general", + ); + }); + + it("builds URL for jellyfin", () => { + expect(getIntegrationApiKeyUrl("http://media.local:8096", "jellyfin")).toBe( + "http://media.local:8096/web/index.html#!/dashboard/keys", + ); + }); + + it("returns null for integrations without apiKeySettingsPath", () => { + expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull(); + }); + + it("returns null for empty URL", () => { + expect(getIntegrationApiKeyUrl("", "sonarr")).toBeNull(); + }); + + it("strips trailing slash from base URL", () => { + expect(getIntegrationApiKeyUrl("http://sonarr.local/", "sonarr")).toBe("http://sonarr.local/settings/general"); + }); +}); diff --git a/packages/definitions/src/test/docker-integration-match.spec.ts b/packages/definitions/src/test/docker-integration-match.spec.ts new file mode 100644 index 000000000..2a5db3a14 --- /dev/null +++ b/packages/definitions/src/test/docker-integration-match.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { + extractContainerImageName, + matchIntegrationKind, + matchIntegrationKindFromContainer, +} from "../docker-integration-match"; + +describe("extractContainerImageName", () => { + it("extracts name from full image reference", () => { + expect(extractContainerImageName("linuxserver/sonarr:latest")).toBe("sonarr"); + }); + + it("extracts name from ghcr image", () => { + expect(extractContainerImageName("ghcr.io/org/radarr:v5")).toBe("radarr"); + }); + + it("handles plain image name", () => { + expect(extractContainerImageName("jellyfin")).toBe("jellyfin"); + }); + + it("handles empty string", () => { + expect(extractContainerImageName("")).toBe(""); + }); +}); + +describe("matchIntegrationKind", () => { + it("matches exact kind name", () => { + expect(matchIntegrationKind("sonarr")).toBe("sonarr"); + }); + + it("matches case-insensitively", () => { + expect(matchIntegrationKind("Sonarr")).toBe("sonarr"); + }); + + it("matches icon slug for pi-hole", () => { + expect(matchIntegrationKind("pi-hole")).toBe("piHole"); + }); + + it("matches alias pihole", () => { + expect(matchIntegrationKind("pihole")).toBe("piHole"); + }); + + it("matches alias adguardhome", () => { + expect(matchIntegrationKind("adguardhome")).toBe("adGuardHome"); + }); + + it("matches alias homeassistant", () => { + expect(matchIntegrationKind("homeassistant")).toBe("homeAssistant"); + }); + + it("matches substring in image name", () => { + expect(matchIntegrationKind("hotio-sonarr")).toBe("sonarr"); + }); + + it("returns null for unknown", () => { + expect(matchIntegrationKind("nginx")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(matchIntegrationKind("")).toBeNull(); + }); +}); + +describe("matchIntegrationKindFromContainer", () => { + it("matches from image name first", () => { + expect( + matchIntegrationKindFromContainer({ + image: "linuxserver/sonarr:latest", + name: "my-sonarr", + }), + ).toBe("sonarr"); + }); + + it("falls back to container name", () => { + expect( + matchIntegrationKindFromContainer({ + image: "custom/myapp:latest", + name: "radarr", + }), + ).toBe("radarr"); + }); + + it("returns null when neither matches", () => { + expect( + matchIntegrationKindFromContainer({ + image: "nginx:latest", + name: "web-proxy", + }), + ).toBeNull(); + }); + + it("excludes dockerHub even when image contains docker", () => { + expect( + matchIntegrationKindFromContainer({ + image: "portainer/portainer-ce:latest", + name: "docker-proxy", + }), + ).toBeNull(); + }); + + it("excludes ical from container matching", () => { + expect( + matchIntegrationKindFromContainer({ + image: "ical-relay:latest", + name: "ical", + }), + ).toBeNull(); + }); + + it("excludes github from container matching", () => { + expect( + matchIntegrationKindFromContainer({ + image: "github-runner:latest", + name: "github-actions", + }), + ).toBeNull(); + }); +}); diff --git a/packages/definitions/src/test/integration-url-template.spec.ts b/packages/definitions/src/test/integration-url-template.spec.ts new file mode 100644 index 000000000..05e7a4e9b --- /dev/null +++ b/packages/definitions/src/test/integration-url-template.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { getIntegrationDefaultPort } from "../integration"; +import { buildIntegrationUrl } from "../integration-url-template"; + +describe("buildIntegrationUrl", () => { + it("builds subdomain URL", () => { + expect(buildIntegrationUrl("sonarr", "homelab.local", "subdomain")).toBe("https://sonarr.homelab.local"); + }); + + it("builds hostPort URL with default port", () => { + expect(buildIntegrationUrl("sonarr", "192.168.1.1", "hostPort")).toBe("http://192.168.1.1:8989"); + }); + + it("uses docker port over default port", () => { + expect(buildIntegrationUrl("sonarr", "192.168.1.1", "hostPort", 9999)).toBe("http://192.168.1.1:9999"); + }); + + it("builds hostPort URL without port when none known", () => { + expect(buildIntegrationUrl("mock", "192.168.1.1", "hostPort")).toBe("http://192.168.1.1"); + }); + + it("strips trailing slashes from host", () => { + expect(buildIntegrationUrl("sonarr", "homelab.local/", "subdomain")).toBe("https://sonarr.homelab.local"); + expect(buildIntegrationUrl("sonarr", "homelab.local///", "subdomain")).toBe("https://sonarr.homelab.local"); + }); + + it("returns empty string for empty host", () => { + expect(buildIntegrationUrl("sonarr", "", "subdomain")).toBe(""); + }); + + it("uses pi-hole slug for subdomain mode", () => { + expect(buildIntegrationUrl("piHole", "example.com", "subdomain")).toBe("https://pi-hole.example.com"); + }); + + it("reads default ports from integration definitions", () => { + expect(getIntegrationDefaultPort("radarr")).toBe(7878); + expect(getIntegrationDefaultPort("prowlarr")).toBe(9696); + expect(getIntegrationDefaultPort("jellyfin")).toBe(8096); + }); +}); diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts new file mode 100644 index 000000000..bef83fea6 --- /dev/null +++ b/packages/definitions/src/widget-integration-map.ts @@ -0,0 +1,113 @@ +import { createDocumentationLink } from "./docs"; +import type { IntegrationKind } from "./integration"; +import { getIntegrationKindsByCategory } from "./integration"; +import type { WidgetKind } from "./widget"; + +/** + * Static mapping of widget kinds to the integration kinds they support. + * Mirrors the supportedIntegrations declared in each widget definition + * but lives in @homarr/definitions so server-side code (API layer) + * can use it without importing @homarr/widgets. + */ +export const widgetIntegrationSupport: Partial> = { + dnsHoleSummary: getIntegrationKindsByCategory("dnsHole"), + dnsHoleControls: getIntegrationKindsByCategory("dnsHole"), + "smartHome-entityState": getIntegrationKindsByCategory("smartHomeServer"), + "smartHome-executeAutomation": getIntegrationKindsByCategory("smartHomeServer"), + mediaServer: getIntegrationKindsByCategory("mediaService"), + calendar: getIntegrationKindsByCategory("calendar"), + downloads: getIntegrationKindsByCategory("downloadClient"), + "mediaRequests-requestList": getIntegrationKindsByCategory("mediaRequest"), + "mediaRequests-requestStats": getIntegrationKindsByCategory("mediaRequest"), + mediaTranscoding: getIntegrationKindsByCategory("mediaTranscoding"), + networkControllerSummary: getIntegrationKindsByCategory("networkController"), + networkControllerStatus: getIntegrationKindsByCategory("networkController"), + indexerManager: getIntegrationKindsByCategory("indexerManager"), + healthMonitoring: getIntegrationKindsByCategory("healthMonitoring"), + firewall: getIntegrationKindsByCategory("firewall"), + notifications: getIntegrationKindsByCategory("notifications"), + mediaReleases: ["emby", "jellyfin", "plex"], + systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances"], + systemDisks: ["dashDot", "openmediavault", "truenas", "unraid"], + coolify: ["coolify"], + timetable: ["searchCh"], + "immich-serverStats": ["immich"], + "immich-albumCarousel": ["immich"], + tracearr: ["tracearr"], + speedtestTracker: ["speedtestTracker"], + anchorNote: ["anchor"], +}; + +export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => { + const result: WidgetKind[] = []; + for (const [widgetKind, supportedIntegrations] of Object.entries(widgetIntegrationSupport)) { + if (supportedIntegrations.includes(integrationKind)) { + result.push(widgetKind as WidgetKind); + } + } + return result; +}; + +export interface DefaultWidgetConfig { + kind: WidgetKind; + width: number; + height: number; + options?: Record; + skip?: boolean; +} + +export const defaultWidgetConfigs: DefaultWidgetConfig[] = [ + { kind: "clock", width: 2, height: 1 }, + { kind: "weather", width: 2, height: 1, options: { showCity: true, hasForecast: true, forecastDayCount: 3 } }, + { kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } }, +]; + +export const generalWidgets: WidgetKind[] = defaultWidgetConfigs + .filter((config) => !config.skip) + .map((config) => config.kind); + +export const featuredIntegrations: readonly IntegrationKind[] = [ + "sonarr", + "radarr", + "prowlarr", + "sabNzbd", + "qBittorrent", + "seerr", + "jellyfin", +]; + +export const hiddenFromOnboarding = new Set([ + "searchCh", + "github", + "dockerHub", + "gitlab", + "npm", + "codeberg", + "linuxServerIO", + "gitHubContainerRegistry", + "quay", + "mock", +]); + +export const defaultBookmarkApps = [ + { + name: "Homarr Docs", + href: createDocumentationLink("/docs/getting-started"), + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg", + }, + { + name: "Homarr GitHub", + href: "https://github.com/homarr-labs/homarr", + iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/github.svg", + }, + { + name: "Help Translate", + href: createDocumentationLink("/docs/community/translations"), + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg", + }, + { + name: "Support Homarr", + href: "https://opencollective.com/homarr", + iconUrl: "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets@latest/assets/opencollective.svg", + }, +]; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index d6305a08e..fec9eb719 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -38,5 +38,6 @@ export const widgetKinds = [ "tracearr", "speedtestTracker", "umami", + "ups", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/docker/src/shared.ts b/packages/docker/src/shared.ts index f50bd32c7..e98695278 100644 --- a/packages/docker/src/shared.ts +++ b/packages/docker/src/shared.ts @@ -11,3 +11,23 @@ export const containerStateColorMap = { removing: "pink", dead: "dark", } satisfies Record; + +export const memoryUsageColor = (number: number, state: string): MantineColor => { + const mbUsage = number / 1024 / 1024; + if (mbUsage === 0 && state !== "running") return "red"; + if (mbUsage < 128) return "green"; + if (mbUsage < 256) return "yellow"; + if (mbUsage < 512) return "orange"; + return "red"; +}; + +export const cpuUsageColor = (number: number, state: string): MantineColor => { + if (number === 0 && state !== "running") return "red"; + if (number < 40) return "green"; + if (number < 60) return "yellow"; + if (number < 90) return "orange"; + return "red"; +}; + +export const safeValue = (value?: number, fallback = 0): number => + value !== undefined && !isNaN(value) ? value : fallback; diff --git a/packages/forms-collection/src/index.tsx b/packages/forms-collection/src/index.tsx index 218655cb5..7621bed43 100644 --- a/packages/forms-collection/src/index.tsx +++ b/packages/forms-collection/src/index.tsx @@ -5,3 +5,5 @@ export * from "./icon-picker/icon-picker"; export * from "./new-app/icon-matcher"; export * from "./upload-media/upload-media"; + +export * from "./user-create/user-create-password-fields"; diff --git a/packages/forms-collection/src/new-app/_form.tsx b/packages/forms-collection/src/new-app/_form.tsx index 05c7e3f6f..2bce21034 100644 --- a/packages/forms-collection/src/new-app/_form.tsx +++ b/packages/forms-collection/src/new-app/_form.tsx @@ -1,7 +1,7 @@ "use client"; import type { ChangeEventHandler } from "react"; -import { useEffect, useRef } from "react"; +import { useEffect, useImperativeHandle, useRef } from "react"; import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core"; import { useDebouncedValue, useDisclosure } from "@mantine/hooks"; import type { z } from "zod/v4"; @@ -17,6 +17,19 @@ import { findBestIconMatch } from "./icon-matcher"; type FormType = z.infer; +const toFormValues = (values: FormType | undefined): FormType => ({ + name: values?.name ?? "", + description: values?.description ?? "", + iconUrl: values?.iconUrl ?? "", + href: values?.href ?? "", + pingUrl: values?.pingUrl ?? "", +}); + +export interface AppFormHandle { + submit: () => Promise; + isDirty: () => boolean; +} + interface AppFormProps { showBackToOverview: boolean; buttonLabels: { @@ -24,8 +37,10 @@ interface AppFormProps { submitAndCreateAnother?: string; }; initialValues?: FormType; - handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void; + handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void | Promise; isPending: boolean; + hideButtons?: boolean; + formRef?: React.Ref; } export const AppForm = ({ @@ -34,19 +49,31 @@ export const AppForm = ({ handleSubmit: originalHandleSubmit, initialValues, isPending, + hideButtons, + formRef, }: AppFormProps) => { const t = useI18n(); const form = useZodForm(appManageSchema, { - initialValues: { - name: initialValues?.name ?? "", - description: initialValues?.description ?? "", - iconUrl: initialValues?.iconUrl ?? "", - href: initialValues?.href ?? "", - pingUrl: initialValues?.pingUrl ?? "", - }, + initialValues: toFormValues(initialValues), }); + const initialValuesKey = [ + initialValues?.name, + initialValues?.description, + initialValues?.iconUrl, + initialValues?.href, + initialValues?.pingUrl, + ].join("\0"); + + useEffect(() => { + if (!initialValues || form.isDirty()) { + return; + } + + form.initialize(toFormValues(initialValues)); + }, [initialValuesKey, form, initialValues]); + // Debounce the name value with 200ms delay const [debouncedName] = useDebouncedValue(form.values.name, 200); @@ -62,6 +89,29 @@ export const AppForm = ({ originalHandleSubmit(values, redirect, afterSuccess); }; + useImperativeHandle( + formRef, + () => ({ + submit: () => + new Promise((resolve) => { + form.onSubmit( + async (values) => { + try { + await Promise.resolve(handleSubmit(values)); + form.initialize(values); + resolve(true); + } catch { + resolve(false); + } + }, + () => resolve(false), + )(); + }), + isDirty: () => form.isDirty(), + }), + [form, handleSubmit], + ); + const [opened, { open, close }] = useDisclosure((initialValues?.pingUrl?.length ?? 0) > 0); const handleClickDifferentUrlPing: ChangeEventHandler = () => { @@ -92,32 +142,32 @@ export const AppForm = ({ } }, [debouncedName, iconsData]); - return ( -
- - - -