diff --git a/.cursor/rules/codebase-context.mdc b/.cursor/rules/codebase-context.mdc index b04b1db1d..48528cbe9 100644 --- a/.cursor/rules/codebase-context.mdc +++ b/.cursor/rules/codebase-context.mdc @@ -66,8 +66,6 @@ homarr/ │ ├── analytics/ # Server-side analytics (Umami) │ ├── server-settings/ # Server setting keys/types │ ├── settings/ # User-facing settings UI context -│ ├── old-import/ # Legacy Homarr import -│ ├── old-schema/ # Legacy Homarr zod schemas │ └── cli/ # Node CLI for ops (brocli) ├── tooling/ │ ├── typescript/ # Base tsconfig diff --git a/.cursor/rules/mantine-datatable-migration.mdc b/.cursor/rules/mantine-datatable-migration.mdc index 08d2b8202..55b48fb41 100644 --- a/.cursor/rules/mantine-datatable-migration.mdc +++ b/.cursor/rules/mantine-datatable-migration.mdc @@ -263,15 +263,6 @@ Even hidden options need translation keys or the translation test fails: "widget.mywidget.option.columnWidths.label": "Column widths" ``` -### Old-import mappings - -Add to `packages/old-import/src/widgets/options.ts`: - -```typescript -columnOrder: () => undefined, -columnWidths: () => undefined, -``` - ### Dynamic storeKey Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding: @@ -610,13 +601,12 @@ if (isEditMode) rowContextMenuHandler = undefined; 4. Add CSS file for transparent backgrounds (if widget context) 5. Add responsive column visibility (if widget context) 6. Add hidden options + hydration logic (if column persistence needed) -7. Add old-import mappings for new options -8. Add translation keys (including hidden option labels) -9. Add context menu if row actions exist -10. Add row expansion if detail view exists -11. Add error handling on all mutations -12. Add accessibility attributes -13. Test at multiple widget widths -14. Update docs in `apps/docs/docs/widgets/` if applicable -15. Run `pnpm turbo typecheck` — catches old-import mismatches -16. Run translation spec — catches missing keys +7. Add translation keys (including hidden option labels) +8. Add context menu if row actions exist +9. Add row expansion if detail view exists +10. Add error handling on all mutations +11. Add accessibility attributes +12. Test at multiple widget widths +13. Update docs in `apps/docs/docs/widgets/` if applicable +14. Run `pnpm turbo typecheck` +15. Run translation spec — catches missing keys diff --git a/AGENTS.md b/AGENTS.md index 6c3d36da4..e9a25c2ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,8 +40,6 @@ homarr/ │ ├── analytics/ # Server-side analytics (Umami) │ ├── server-settings/ # Server setting keys/types │ ├── settings/ # User-facing settings UI context -│ ├── old-import/ # Legacy Homarr import -│ ├── old-schema/ # Legacy Homarr zod schemas │ └── cli/ # Node CLI for ops (brocli) ├── tooling/ │ ├── typescript/ # Base tsconfig diff --git a/MANTINE.md b/MANTINE.md index fa9c8a3e7..a87cbbd48 100644 --- a/MANTINE.md +++ b/MANTINE.md @@ -4,7 +4,6 @@ This index lists Mantine documentation pages formatted for LLMs. Each link points to a standalone Markdown file under the /llms path. For a single consolidated file with all content, use: - - https://mantine.dev/llms-full.txt ## FAQ @@ -54,7 +53,7 @@ For a single consolidated file with all content, use: - [How to integrate custom input with use-form hook?](https://mantine.dev/llms/q-custom-input-use-form.md): Learn how to add use-form support for custom inputs - [How to prevent Modal from closing?](https://mantine.dev/llms/q-how-to-prevent-modal-from-closing.md): Learn how to prevent Modal from closing when the user clicks outside of it or presses the Escape key - [How to scroll to the top of the form if the form is submitted with errors?](https://mantine.dev/llms/q-scroll-to-the-top-of-the-form.md): Learn how to call a function when the form is submitted with errors -- [How to update Mantine dependencies?](https://mantine.dev/llms/q-how-to-update-dependencies.md): Learn how to update @mantine/_, @mantinex/_ and postcss-preset-mantine dependencies manually or with ncu script +- [How to update Mantine dependencies?](https://mantine.dev/llms/q-how-to-update-dependencies.md): Learn how to update @mantine/*, @mantinex/* and postcss-preset-mantine dependencies manually or with ncu script - [How to use Dropzone with @mantine/form?](https://mantine.dev/llms/q-how-to-use-dropzone-with-form.md): Learn how to use Dropzone with @mantine/form to handle file selection state - [How to use Mantine template on GitHub?](https://mantine.dev/llms/q-templates-usage.md): Learn how to create a new repository based on Mantine template - [I get hydration warning about data-mantine-color-scheme attribute, what does it mean?](https://mantine.dev/llms/q-color-scheme-hydration-warning.md): Learn how hydration works and how to fix warnings diff --git a/apps/docs/blog/2024/09-23-version-1.0/index.mdx b/apps/docs/blog/2024/09-23-version-1.0/index.mdx index e9c016833..d3de1e7e9 100644 --- a/apps/docs/blog/2024/09-23-version-1.0/index.mdx +++ b/apps/docs/blog/2024/09-23-version-1.0/index.mdx @@ -13,8 +13,6 @@ This will be a one time occurrence and version > ``1.0`` will have less breaking This time more important than every; **read the breaking changes** list and **create a backup** before updating. Some third party installation methods may take multiple days or weeks to update to the new version. -See [this guide on how to import your previous Homarr dashboard](/docs/getting-started/after-the-installation#importing-a-zip-from-version-before-100) - ## Changes at a glance - **Asynchronous integrations system**: In versions prior to ``1.0``, we fetched data upon each request synchronously. This means that the user had to wait for the requests to finish so any data would appear. diff --git a/apps/docs/blog/2025/01-19-migration-guide-1.0/index.mdx b/apps/docs/blog/2025/01-19-migration-guide-1.0/index.mdx index 0525db0e4..fc4a49d47 100644 --- a/apps/docs/blog/2025/01-19-migration-guide-1.0/index.mdx +++ b/apps/docs/blog/2025/01-19-migration-guide-1.0/index.mdx @@ -28,28 +28,15 @@ Please note that if you are using docker you need to specify a diffrent external So in your compose file the ports would be `7576:7575` instead of `7575:7575`. ## Import your data -Once the installation is completed you can access the new installation. During the onboarding you'll need to select `Import from Homarr before 1.0`. -![Onboarding initial step](./img/onboarding-step-1.png) +:::warning Legacy guide -After that you can upload the zip file you downloaded in the first step. +The pre-1.0 ZIP importer described by this historical migration guide is no longer included in current Homarr releases. Keep the old installation and export available while you recreate the required apps and boards in a supported release. SQLite database backups from current Homarr releases can still be restored from the onboarding welcome screen. -![Onboarding import dropzone](./img/old-homarr-import-dropzone.png) - -After that you'll get a list with all your boards that can be imported. You can configure how it should be imported and can select which screensizes it should import. - -![Onboarding import list](./img/old-homarr-import-choose-imported-data.png) - -Once you have selected everything you can click on `Confirm import and continue` which will open the encryption key input dialog where you can input the secret key you got during the export. - -![Onboarding import key input](./img/old-homarr-import-decryption-token.png) - -After that you can continue the onboarding and you should see all your boards and data in the new installation. - -See [After the installation](https://homarr.dev/docs/getting-started/after-the-installation) for more information. +::: ## Remove the old installation After you are happy with the new installation and everything is working as expected you can remove the old installation. Please make sure to backup the data before you remove the old installation. -Also once the port 7575 is free again you can change the port mapping of the new installation to `7575:7575` if you want to. \ No newline at end of file +Also once the port 7575 is free again you can change the port mapping of the new installation to `7575:7575` if you want to. diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index 4fcd403bc..0b264c85e 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -6,18 +6,10 @@ tags: - Configuration --- -import imageStartFromScratchStepServerSettings from "./img/onboarding-from-scratch-step-server-settings.png"; -import imageStartFromScratchStepAdminUser from "./img/onboarding-from-scratch-step-admin-user.png"; -import imageStartFromScratchStepExternalAdminGroup from "./img/onboarding-from-scratch-step-external-admin-group.png"; -import imageStartFromScratchStepFinish from "./img/onboarding-from-scratch-step-finish.png"; import manageBoardHeaderEditMode from "./img/manage-board-header-edit-mode.png"; import manageBoardHeaderAddItem from "./img/manage-board-header-add-item-menu.png"; import manageBoardHeaderChooseItem from "./img/manage-board-header-choose-item.png"; import manageBoardNoApp from "./img/manage-board-no-app.png"; -import manageExportOldHomarr from "./img/old-homarr-export-data.png"; -import manageImportOldHomarrDropzone from "./img/old-homarr-import-dropzone.png"; -import manageImportOldHomarrChooseData from "./img/old-homarr-import-choose-imported-data.png"; -import manageImportDecryptionToken from "./img/old-homarr-import-decryption-token.png"; :::tip @@ -29,117 +21,50 @@ Congratulations on installing Homarr. Now it's time to get yourself familiar wit ## Onboarding -The onboard will show the first time you start Homarr. -This process will help you with your first steps and adding some initial data, such as the administrator account. +Onboarding appears the first time Homarr starts. Its goal is to create a useful responsive board, not merely an +administrator account. The setup uses one guided studio, so you can keep the safe defaults and finish quickly or review +every detected service before anything is added to the board. :::note -If you see this screen repeatitly after you restarted the Homarr container, your Docker mounting paths may be missing, +If you see this screen repeatedly after restarting the Homarr container, your Docker mounting paths may be missing, incorrect or read-only. Read [our installation instructions](/docs/getting-started/installation/docker) again to fix this issue. ::: -You can either import a previous dashboard using the ZIP archive or start from scratch: +### Restore or start fresh -![Screenshot of Step 1 onboarding](./img/onboarding-step-1.png) +SQLite installations can restore a complete Homarr database backup from the welcome screen. This restores current +boards, people, apps, integrations, and settings. The pre-1.0 ZIP importer is no longer included. -You may refresh anytime and continue from a different device - the onboarding is saved immediately on the server for your convenience. +For a new installation, Homarr first creates either a credentials administrator or an administrator group matching your +LDAP/OIDC provider. The browser that starts setup claims the first-run session; refreshes are safe, while another browser +cannot change credentials or integrations during that setup. -### Starting from scratch +### Setup studio -Depending on your auth provider configuration you will either first see the admin user creation form or the external group creation form. -If the credentials provider was selected, you'll be prompted to create an admin user for Homarr. -This user cannot be deleted and has full control over Homarr and all of its boards. -You must choose a username and password (both can later be changed). -We enforce strict password requirements when you enter your password to protect your instance if you accidentally -expose it to the internet. +The studio keeps related choices together: -{"Screenshot +1. **Essentials** sets the default language, color scheme, analytics preference, and the usual host, IP address, or domain used by your services. +2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup. +3. **Connect** combines explicit `homarr.*` or Homepage Docker labels with image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. +4. **Board** previews the protected Mobile and Base layouts, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. +5. **Extend** explains and optionally configures Homarr Assistant, the community Workshop, and the separate Homarr MCP endpoint. +6. **Review** tests and saves the selected connections, creates the chosen apps and widgets, and configures the exact first board without clearing unrelated data. -If you selected external providers (such as LDAP or OIDC), you'll be prompted to create an admin group. -This group will be used to synchronize users from your provider to Homarr. -When a user logs in through the provider, any of their groups that match groups in Homarr will be assigned to the user. -The name of the group must match exactly the name of the group in the provider. +Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket +is unavailable, which is useful for remote Docker, rootless Docker, Synology, and mixed deployments. On Kubernetes or +bare-metal installations without a Docker socket, enter services manually and use the same integration catalog. -{"Screenshot +After setup, **Open my board** takes you directly to the configured board. The in-product tour then introduces edit mode, +responsive layouts, widgets, and search without delaying completion. -Next, you can configure some basic settings for Homarr. We recommend to leave all settings at default for most users. -Here you can also disable analytics. Please note that our analytics are completly anonymous and is fully compliant with GDPR and PECR. - -{"Screenshot - -### Setting up integrations - -After configuring server settings, the onboarding guides you through setting up your integrations. - -#### Docker service discovery - -If Docker is available (socket mounted), Homarr automatically detects running containers and matches them to known integration types. -Matching integrations are pre-selected in the integration list, saving you from manually picking each one. - -#### URL templating - -You can define a base URL template to auto-fill integration URLs in bulk. -Two modes are supported: - -- **Subdomain mode** - e.g. `{app}.example.com` where `{app}` is replaced with the integration name -- **Port mode** - e.g. `192.168.1.1:{port}` where `{port}` is replaced with the integration's default port - -#### API key helper links - -For integrations that require API keys (such as Sonarr, Radarr, SABnzbd, or Seerr), a helper link opens the relevant settings page in the target application so you can quickly copy your key. - -#### Creating apps from Docker containers - -You can optionally auto-create bookmark apps for all discovered Docker containers, making them immediately available on your board. - -:::note - -Release providers and Search.ch are configured directly in their widgets instead of the integrations page. - -::: - -After this step, you finished the onboarding. -The onboarding can no longer be started again. -From here you can click on one of the provided links to continue your journey. -We recommend you to continue with the section [creating your first board on this page](#creating-your-first-board). - -{"Screenshot - -### Importing a ZIP from version before 1.0.0 - -This is recommended when you used Homarr before and want to re-import your data. -It is important that you first migrate correctly using [the list of breaking changes for 1.0](/blog/2024/09/23/version-1.0). - -Export from old Homarr - -**How to export and import data** - -1. Navigate to your old Homarr, open your management pages, open the tools submenu and open `Migrate to 1.0`. -2. Check the contents that you want to export. We recommend you to export all data. -3. Click on the export data button -4. Save the ZIP file, that is being downloaded, on your device. If you selected "integrations" or "users", you also get an encryption / decryption key. - Copy it and do not loose it! -5. Go to your new >1.0.0 Homarr instance and start the onboarding. Choose `Import from Homarr before 1.0` -6. Drag and drop your previously downloaded ZIP archive into the file upload:
-7. Next, choose the data that you want to import and how to import it. Since screen sizes were removed in 1.0, we create a new board for every screen size by default. We recommend you to only keep the best layout and adjust it later in 1.0.0 - but you can also import all of them and pick later.
-8. If you previously exported integrations or users, you will be asked to decrypt the data safely using your obtained encryption token. Paste into the modal and confirm your action.
-9. Next, you can [follow the same setup as when starting from scratch](#starting-from-scratch). After you completed the onboarding, all of your data will be migrated and available. - -## Creating your first board +## Creating another board In Homarr, you organize your apps and widgets on boards. Boards use fixed-size tiles so the same widget keeps the same proportions on every screen. Drag, resize, and section controls are available in edit mode. -Let's create your first board by navigating here: `Manage` > `Boards` > `New board`: +Onboarding already created the first board. To create another one, navigate to `Manage` > `Boards` > `New board`: ![](./img/manage-create-board-button.png) A board must always have a name and can either be public or private. diff --git a/apps/docs/docs/management/apps/index.mdx b/apps/docs/docs/management/apps/index.mdx index efd42277d..a2fde8e48 100644 --- a/apps/docs/docs/management/apps/index.mdx +++ b/apps/docs/docs/management/apps/index.mdx @@ -51,6 +51,6 @@ Note that the ping URL only accepts `http://` and `https://` URLs — custom URI ## List of apps -On the apps management page you find all apps that exist in your Homarr instance. You can edit and delete them. +On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page. -![Apps list](./img/app-list.png) +Any signed-in user can view this list. Creating, editing, and deleting apps requires the corresponding app permission; board permissions are not required. diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index aaf0f36cd..d68a94c51 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -20,6 +20,7 @@ You can create as many boards as you want. Boards can be restricted to user groups. A user may also have a "home board" which they will see at the root path (e.g. my-homarr.com or my-homarr.com/board). Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home) or via the management pages. +The management page includes a compact structural preview of each board's Base layout so you can recognize it before opening it. Names of boards must be unique because this will be used in the URL to load the desired board. diff --git a/apps/docs/docs/management/integrations/index.mdx b/apps/docs/docs/management/integrations/index.mdx index de819e692..f4960560d 100644 --- a/apps/docs/docs/management/integrations/index.mdx +++ b/apps/docs/docs/management/integrations/index.mdx @@ -13,9 +13,9 @@ Check [the available integrations](/docs/category/integrations) for further info ## Create an integration -To create an integration, you can choose the supported application from the dropdown at the top right: +To create an integration, choose **New integration** and select the supported application from the searchable grid. The grid groups related services visually and shows which widgets each integration supports. -![](./img/create-integration-dropdown.png) +The integrations management page uses a searchable list. Each row shows the integration type and URL, with the actions you have permission to use. Next, you'll need to fill out the required fields to configure the integration: diff --git a/apps/docs/docs/management/search-engines/index.mdx b/apps/docs/docs/management/search-engines/index.mdx index 07f2f3098..186853d8c 100644 --- a/apps/docs/docs/management/search-engines/index.mdx +++ b/apps/docs/docs/management/search-engines/index.mdx @@ -8,8 +8,7 @@ tags: # Search Engines Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `CTRL + K`. - -![](./img/search-engines.png) +The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine. You can add any search engine that has a search URL that accepts a search term as a parameter. diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 212f54839..8675f6dc2 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -48,8 +48,7 @@ "@homarr/modals": "workspace:^0.1.0", "@homarr/modals-collection": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0", - "@homarr/old-import": "workspace:^0.1.0", - "@homarr/old-schema": "workspace:^0.1.0", + "@homarr/onboarding": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0", "@homarr/settings": "workspace:^0.1.0", diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 7c3276d9b..061a08051 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -4,9 +4,9 @@ import { Box, Paper, Stack, Text } from "@mantine/core"; import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; +import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview"; import { useScopedI18n } from "@homarr/translation/client"; -import { getRepresentativeLayoutWidth } from "../_layout-utils"; import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; import { getBoardLaneColumnCount, diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.tsx index 7d54a0e51..d14347652 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.tsx @@ -4,21 +4,21 @@ import { Badge, Box, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/cor import { IconLayoutGrid } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; +import type { BoardPreviewLayout } from "@homarr/boards/layout-preview"; +import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview"; import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; import { widgetCatalogIcons } from "@homarr/widgets/catalog"; import type { Board } from "../../_types"; -import type { BoardLayout } from "../../_layout-utils"; -import { getRepresentativeLayoutWidth, projectBoardLayout } from "../../_layout-utils"; import classes from "./_layout-preview.module.css"; interface Props { board: Board; - layout: BoardLayout; - layouts: BoardLayout[]; - sourceLayout: BoardLayout; + layout: BoardPreviewLayout; + layouts: BoardPreviewLayout[]; + sourceLayout: BoardPreviewLayout; apps: RouterOutputs["app"]["byIds"]; } diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-utils.spec.ts b/apps/nextjs/src/app/[locale]/boards/_layout-utils.spec.ts deleted file mode 100644 index ff33fa20e..000000000 --- a/apps/nextjs/src/app/[locale]/boards/_layout-utils.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock"; -import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock"; -import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock"; -import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock"; -import { getRepresentativeLayoutWidth, projectBoardLayout } from "./_layout-utils"; - -const mobile = { - id: "mobile", - name: "Mobile", - columnCount: 3, - leftGutterColumnCount: 0, - rightGutterColumnCount: 0, - breakpoint: 0, - role: "mobile" as const, -}; -const custom = { - id: "tablet", - name: "Tablet", - columnCount: 6, - leftGutterColumnCount: 0, - rightGutterColumnCount: 0, - breakpoint: 480, - role: "custom" as const, -}; -const base = { - id: "base", - name: "Base", - columnCount: 12, - leftGutterColumnCount: 0, - rightGutterColumnCount: 0, - breakpoint: 768, - role: "base" as const, -}; - -describe("layout representative widths", () => { - test("uses a phone width, activation midpoint, and desktop minimum", () => { - const layouts = [mobile, custom, base]; - expect(getRepresentativeLayoutWidth(mobile, layouts)).toBe(390); - expect(getRepresentativeLayoutWidth(custom, layouts)).toBe(624); - expect(getRepresentativeLayoutWidth(base, layouts)).toBe(1280); - }); -}); - -describe("projectBoardLayout", () => { - test("returns exact saved positions without recursively re-projecting the active layout", () => { - const rootSection = new EmptySectionMockBuilder({ id: "root" }).build(); - const container = new ContainerSectionMockBuilder({ id: "container" }) - .addLayout({ layoutId: mobile.id, parentSectionId: rootSection.id, width: 3, height: 2 }) - .build(); - const board = new BoardMockBuilder().addLayout(mobile).addSections([rootSection, container]).build(); - - expect(projectBoardLayout(board, mobile, mobile)).toEqual([ - expect.objectContaining({ id: container.id, sectionId: rootSection.id, width: 3, height: 2 }), - ]); - }); - - test("uses production ordering and sizing for items and containers", () => { - const rootSection = new EmptySectionMockBuilder({ id: "root" }).build(); - const container = new ContainerSectionMockBuilder({ id: "container" }) - .addLayout({ layoutId: base.id, parentSectionId: rootSection.id, width: 6, height: 3, xOffset: 6, yOffset: 0 }) - .build(); - const rootItem = new ItemMockBuilder({ id: "root-item", kind: "clock" }) - .addLayout({ layoutId: base.id, sectionId: rootSection.id, width: 5, height: 2, xOffset: 0, yOffset: 0 }) - .build(); - const nestedItem = new ItemMockBuilder({ id: "nested-item", kind: "clock" }) - .addLayout({ layoutId: base.id, sectionId: container.id, width: 4, height: 1, xOffset: 0, yOffset: 0 }) - .build(); - const board = new BoardMockBuilder() - .addLayout(mobile) - .addSections([rootSection, container]) - .addItems([rootItem, nestedItem]) - .build(); - - const projected = projectBoardLayout(board, base, mobile); - - expect(projected.find((element) => element.id === rootItem.id)).toMatchObject({ - type: "item", - width: 3, - height: 2, - xOffset: 0, - yOffset: 0, - sectionId: rootSection.id, - }); - expect(projected.find((element) => element.id === container.id)).toMatchObject({ - type: "section", - width: 3, - xOffset: 0, - yOffset: 2, - sectionId: rootSection.id, - }); - expect(projected.find((element) => element.id === nestedItem.id)).toMatchObject({ - type: "item", - width: 3, - sectionId: container.id, - }); - }); - - test("ignores missing saved positions and supports empty boards", () => { - const rootSection = new EmptySectionMockBuilder({ id: "root" }).build(); - const itemWithoutBasePosition = new ItemMockBuilder({ id: "missing", kind: "clock" }).build(); - const board = new BoardMockBuilder() - .addLayout(mobile) - .addSection(rootSection) - .addItem(itemWithoutBasePosition) - .build(); - - expect(projectBoardLayout(board, base, mobile)).toEqual([]); - }); -}); 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 deleted file mode 100644 index e51149147..000000000 --- a/apps/nextjs/src/app/[locale]/init/_components/init-step-card.tsx +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 870cbdc21..000000000 --- a/apps/nextjs/src/app/[locale]/init/_constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const onboardingContentWidth = 64 * 16; -export const onboardingMaxWidth = "90vw"; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/back.tsx b/apps/nextjs/src/app/[locale]/init/_steps/back.tsx deleted file mode 100644 index 7fa98d283..000000000 --- a/apps/nextjs/src/app/[locale]/init/_steps/back.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import { Button } from "@mantine/core"; - -import { clientApi } from "@homarr/api/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useI18n } from "@homarr/translation/client"; - -export const BackToStart = () => { - const t = useI18n(); - const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation(); - - const handleBackToStartAsync = async () => { - await mutateAsync(); - await revalidatePathActionAsync("/init"); - }; - - return ( - - ); -}; 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 deleted file mode 100644 index ff7c2042a..000000000 --- a/apps/nextjs/src/app/[locale]/init/_steps/finish/init-finish.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import type { MantineColor } from "@mantine/core"; -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 { 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 tFinish = await getScopedI18n("init.step.finish"); - - return ( - - - {tFinish("description")} - - - {tFinish("action.goToBoard", { name: "dashboard" })} - - - {isProviderEnabled("credentials") && ( - - {tFinish("action.inviteUser")} - - )} - - - {tFinish("action.docs")} - - - - ); -}; - -interface LinkButtonProps { - href: string; - children: string; - iconProps: IconProps; -} - -interface IconProps { - icon: TablerIcon; - color: MantineColor; -} - -const Icon = ({ icon: IcomComponent, color }: IconProps) => { - return ; -}; - -const InternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => { - return ( - - ); -}; - -const ExternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => { - return ( - - ); -}; 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 deleted file mode 100644 index 98602de73..000000000 --- a/apps/nextjs/src/app/[locale]/init/_steps/group/init-group.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"use client"; - -import { Button, Stack, TextInput } from "@mantine/core"; -import { IconArrowRight } from "@tabler/icons-react"; -import type { z } from "zod/v4"; - -import { clientApi } from "@homarr/api/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -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({ - async onSuccess() { - await revalidatePathActionAsync("/init"); - }, - }); - const form = useZodForm(groupCreateSchema, { - initialValues: { - name: "", - }, - }); - - const handleSubmitAsync = async (values: z.infer) => { - await mutateAsync(values, { - onError(error) { - if (error.data?.code === "CONFLICT") { - form.setErrors({ name: t("common.zod.errors.custom.groupNameTaken") }); - } - }, - }); - }; - - 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 deleted file mode 100644 index c947719c2..000000000 --- a/apps/nextjs/src/app/[locale]/init/_steps/import/file-info-card.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { ActionIcon, Button, Group, Text } from "@mantine/core"; -import type { FileWithPath } from "@mantine/dropzone"; -import { IconPencil } from "@tabler/icons-react"; - -import { formatBytes } from "@homarr/common"; -import { useScopedI18n } from "@homarr/translation/client"; - -import { InitStepCard } from "../../_components/init-step-card"; - -interface FileInfoCardProps { - file: FileWithPath; - onRemove: () => void; -} - -export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => { - const tFileInfo = useScopedI18n("init.step.import.fileInfo"); - return ( - - - - - {file.name} - - - {formatBytes(file.size)} - - - - - - - - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/import/import-dropzone.tsx b/apps/nextjs/src/app/[locale]/init/_steps/import/import-dropzone.tsx deleted file mode 100644 index cea43cabe..000000000 --- a/apps/nextjs/src/app/[locale]/init/_steps/import/import-dropzone.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Group, rem, Text } from "@mantine/core"; -import type { FileWithPath } from "@mantine/dropzone"; -import { Dropzone, MIME_TYPES } from "@mantine/dropzone"; -import { IconFileZip, IconUpload, IconX } from "@tabler/icons-react"; - -import "@mantine/dropzone/styles.css"; - -import { useScopedI18n } from "@homarr/translation/client"; - -interface ImportDropZoneProps { - loading: boolean; - updateFile: (file: FileWithPath) => void; -} - -export const ImportDropZone = ({ loading, updateFile }: ImportDropZoneProps) => { - const tDropzone = useScopedI18n("init.step.import.dropzone"); - return ( - { - const firstFile = files[0]; - if (!firstFile) return; - - updateFile(firstFile); - }} - acceptColor="blue.6" - rejectColor="red.6" - accept={[MIME_TYPES.zip, "application/x-zip-compressed"]} - loading={loading} - multiple={false} - maxSize={1024 * 1024 * 1024 * 64} // 64 MB - onReject={(rejections) => { - console.error( - "Rejected files", - rejections.map( - (rejection) => - `File: ${rejection.file.name} size=${rejection.file.size} fileType=${rejection.file.type}\n - ${rejection.errors.map((error) => error.message).join("\n - ")}`, - ), - ); - }} - > - - - - - - - - - - - -
- - {tDropzone("title")} - - - {tDropzone("description")} - -
-
-
- ); -}; 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 deleted file mode 100644 index c7113b66b..000000000 --- a/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client"; - -import { startTransition, useState } from "react"; -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"; - -export const InitImport = () => { - const [file, setFile] = useState(null); - const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation(); - const [analyseResult, setAnalyseResult] = useState( - null, - ); - - if (!file) { - return ( - - { - const formData = new FormData(); - formData.append("file", file); - - mutate(formData, { - onSuccess: (result) => { - startTransition(() => { - setAnalyseResult(result); - setFile(file); - }); - }, - onError: (error) => { - console.error(error); - }, - }); - }} - /> - - ); - } - - 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 deleted file mode 100644 index 88da9cb71..000000000 --- a/apps/nextjs/src/app/[locale]/init/_steps/integrations/docker-discovery-panel.tsx +++ /dev/null @@ -1,209 +0,0 @@ -"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 deleted file mode 100644 index 71f9345f3..000000000 --- a/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx +++ /dev/null @@ -1,483 +0,0 @@ -"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 deleted file mode 100644 index 5c99d651c..000000000 --- a/apps/nextjs/src/app/[locale]/init/_steps/settings/init-settings.tsx +++ /dev/null @@ -1,129 +0,0 @@ -"use client"; - -import { Button, Group, Stack, Switch, Text } from "@mantine/core"; -import { IconArrowRight } from "@tabler/icons-react"; -import type { z } from "zod/v4"; - -import { clientApi } from "@homarr/api/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useZodForm } from "@homarr/form"; -import type { CheckboxProps } from "@homarr/form/types"; -import { defaultServerSettings } from "@homarr/server-settings"; -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(); - const { mutateAsync } = clientApi.serverSettings.initSettings.useMutation({ - async onSuccess() { - await revalidatePathActionAsync("/init"); - }, - }); - const form = useZodForm(settingsInitSchema, { initialValues: defaultServerSettings }); - - const handleSubmitAsync = async (values: z.infer) => { - await mutateAsync(values); - }; - - return ( -
- - - - {tSection("analytics.title")} - - - - - - - - - {tSection("crawlingAndIndexing.title")} - - - - - - - - - - - - -
- ); -}; - -interface AnalyticsRowProps { - kind: Exclude; - disabled?: boolean; -} - -const AnalyticsRow = ({ kind, ...props }: AnalyticsRowProps & CheckboxProps) => { - const tSection = useI18n("management.page.settings.section"); - - return ( - - ); -}; - -interface CrawlingRowProps { - kind: Exclude< - keyof TranslationObject["management"]["page"]["settings"]["section"]["crawlingAndIndexing"], - "title" | "warning" - >; -} - -const CrawlingRow = ({ kind, ...inputProps }: CrawlingRowProps & CheckboxProps) => { - const tSection = useI18n("management.page.settings.section"); - - return ( - - ); -}; - -const SettingRow = ({ - title, - text, - disabled, - ...inputProps -}: { title: string; text: string; disabled?: boolean } & CheckboxProps) => { - return ( - - - - {title} - - - {text} - - - - - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx b/apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx deleted file mode 100644 index 04a7fc159..000000000 --- a/apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Stack, Text } from "@mantine/core"; -import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react"; - -import { getMantineColor } from "@homarr/common"; -import { dbEnv } from "@homarr/core/infrastructure/db/env"; -import { getScopedI18n } from "@homarr/translation/server"; - -import { InitStepCard } from "../../_components/init-step-card"; -import { InitSqliteRestore } from "./sqlite-restore"; -import { InitStartButton } from "./next-button"; - -export const InitStart = async () => { - const tStart = await getScopedI18n("init.step.start"); - const isSqlite = dbEnv.DRIVER === "better-sqlite3"; - - return ( - - - {tStart("description")} - - } - > - {tStart("action.scratch")} - - } - > - {tStart("action.importOldmarr")} - - {isSqlite && } - - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/start/next-button.tsx b/apps/nextjs/src/app/[locale]/init/_steps/start/next-button.tsx deleted file mode 100644 index 00ecf6f7d..000000000 --- a/apps/nextjs/src/app/[locale]/init/_steps/start/next-button.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -import type { PropsWithChildren, ReactNode } from "react"; -import { Button } from "@mantine/core"; - -import { clientApi } from "@homarr/api/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -import type { OnboardingStep } from "@homarr/definitions"; - -interface InitStartButtonProps { - icon: ReactNode; - preferredStep: OnboardingStep | undefined; -} - -export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren) => { - const { mutateAsync } = clientApi.onboard.nextStep.useMutation(); - - const handleClickAsync = async () => { - await mutateAsync({ preferredStep }); - await revalidatePathActionAsync("/init"); - }; - - return ( - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/start/sqlite-restore.tsx b/apps/nextjs/src/app/[locale]/init/_steps/start/sqlite-restore.tsx deleted file mode 100644 index a1afd8494..000000000 --- a/apps/nextjs/src/app/[locale]/init/_steps/start/sqlite-restore.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Button, Stack, Text } from "@mantine/core"; -import { IconDatabaseImport } from "@tabler/icons-react"; - -import { getMantineColor } from "@homarr/common"; -import { useScopedI18n } from "@homarr/translation/client"; - -import { DatabaseRestoreFlow } from "~/components/backup"; - -export const InitSqliteRestore = () => { - const t = useScopedI18n("init.step.start.action"); - const [expanded, setExpanded] = useState(false); - - if (expanded) { - return ( - - - - {t("restoreSqliteDescription")} - - - - ); - } - - return ( - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/user/init-user-form.tsx b/apps/nextjs/src/app/[locale]/init/_steps/user/init-user-form.tsx deleted file mode 100644 index d3f1c665f..000000000 --- a/apps/nextjs/src/app/[locale]/init/_steps/user/init-user-form.tsx +++ /dev/null @@ -1,75 +0,0 @@ -"use client"; - -import { Button, Stack, TextInput } from "@mantine/core"; -import type { z } from "zod/v4"; - -import { clientApi } from "@homarr/api/client"; -import { signIn } from "@homarr/auth/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useZodForm } from "@homarr/form"; -import { UserCreatePasswordFields } from "@homarr/forms-collection"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useScopedI18n } from "@homarr/translation/client"; -import { userInitSchema } from "@homarr/validation/user"; - -export const InitUserForm = () => { - const t = useScopedI18n("user"); - const tUser = useScopedI18n("init.step.user"); - const { mutateAsync, isPending } = clientApi.user.initUser.useMutation(); - const form = useZodForm(userInitSchema, { - initialValues: { - username: "", - password: "", - confirmPassword: "", - }, - }); - - const handleSubmitAsync = async (values: FormType) => { - await mutateAsync(values, { - onSuccess() { - showSuccessNotification({ - title: tUser("notification.success.title"), - message: tUser("notification.success.message"), - }); - - void signIn("credentials", { - name: values.username, - password: values.password, - redirect: false, - }).then(async () => { - await revalidatePathActionAsync("/init"); - }); - }, - onError: (error) => { - showErrorNotification({ - title: tUser("notification.error.title"), - message: error.message, - }); - }, - }); - }; - - return ( - -
void handleSubmitAsync(values), - (err) => console.log(err), - )} - > - - - - - -
-
- ); -}; - -type FormType = z.infer; 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 deleted file mode 100644 index 16760cd4c..000000000 --- a/apps/nextjs/src/app/[locale]/init/_steps/user/init-user.tsx +++ /dev/null @@ -1,10 +0,0 @@ -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 0013c3170..fbf55ff6a 100644 --- a/apps/nextjs/src/app/[locale]/init/page.tsx +++ b/apps/nextjs/src/app/[locale]/init/page.tsx @@ -1,66 +1,64 @@ -import type { JSX } from "react"; -import { Box, Center, Stack, Text, Title } from "@mantine/core"; +import { headers } from "next/headers"; -import type { MaybePromise } from "@homarr/common/types"; -import { db } from "@homarr/db"; -import type { OnboardingStep } from "@homarr/definitions"; -import { getScopedI18n } from "@homarr/translation/server"; +import { normalizeOnboardingStep } from "@homarr/api/onboarding-step"; +import { auth } from "@homarr/auth/next"; +import { isProviderEnabled } from "@homarr/auth/server"; +import { extractBaseUrlFromHeaders } from "@homarr/common"; +import { dbEnv } from "@homarr/core/infrastructure/db/env"; +import { db, eq } from "@homarr/db"; +import { groups } from "@homarr/db/schema"; +import { everyoneGroup } from "@homarr/definitions"; +import { env as dockerEnv } from "@homarr/docker/env"; +import { OnboardingStudio } from "@homarr/onboarding"; +import { resolveHomarrUrlConfig } from "@homarr/workshop/schema"; -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"; - -const stepComponents: Record MaybePromise)> = { - start: InitStart, - import: InitImport, - user: InitUser, - group: InitGroup, - settings: InitSettings, - integrations: InitIntegrations, - finish: InitFinish, -}; - -const getCurrentOnboardingStepAsync = async () => { - const value = await db.query.onboarding.findFirst(); - if (!value) return { current: "start" as const, previous: null }; - - return { current: value.step, previous: value.previousStep }; -}; +import { DatabaseRestoreFlow } from "~/components/backup"; +import { env } from "~/env"; export default async function InitPage() { - const t = await getScopedI18n("init.step"); - const currentStep = await getCurrentOnboardingStepAsync(); - - const CurrentComponent = stepComponents[currentStep.current]; + const [state, session, requestHeaders, defaultGroup, firstUser, availableBoards] = await Promise.all([ + db.query.onboarding.findFirst(), + auth(), + headers(), + db.query.groups.findFirst({ + where: eq(groups.name, everyoneGroup), + columns: { homeBoardId: true }, + }), + db.query.users.findFirst({ columns: { id: true } }), + db.query.boards.findMany({ columns: { id: true, name: true } }), + ]); + const homeBoardId = defaultGroup?.homeBoardId; + const homeBoard = availableBoards.find((board) => board.id === homeBoardId); + const initialBoard = homeBoard ?? (availableBoards.length === 1 ? availableBoards[0] : null); + const currentStep = normalizeOnboardingStep(state?.step); + const databaseDriver = + dbEnv.DRIVER === "better-sqlite3" ? "sqlite" : dbEnv.DRIVER === "mysql2" ? "mysql" : "postgresql"; + const { workshopApiUrl, workshopWebUrl } = resolveHomarrUrlConfig({ + homarrWebsiteUrl: env.HOMARR_WEBSITE_URL, + workshopApiUrl: env.WORKSHOP_API_URL, + workshopWebUrl: env.WORKSHOP_WEB_URL, + }); + const baseUrl = extractBaseUrlFromHeaders(requestHeaders); return ( - -
- - - - - {t(`${currentStep.current}.title`)} - - - {t(`${currentStep.current}.subtitle`)} - - - - - {CurrentComponent && } - {currentStep.previous === "start" && } - -
-
+ : undefined} + /> ); } diff --git a/apps/nextjs/src/app/[locale]/manage/apps/_app-delete-button.tsx b/apps/nextjs/src/app/[locale]/manage/apps/_app-delete-button.tsx index 2fb1dce56..190737611 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/_app-delete-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/_app-delete-button.tsx @@ -17,6 +17,7 @@ interface AppDeleteButtonProps { export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => { const t = useScopedI18n("app.page.delete"); + const tList = useScopedI18n("app.page.list.action"); const { openConfirmModal } = useConfirmModal(); const { mutate, isPending } = clientApi.app.delete.useMutation(); @@ -50,7 +51,14 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => { }, [app, mutate, t, openConfirmModal]); return ( - + ); diff --git a/apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/page.tsx index e717e5787..a931b2ece 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/page.tsx @@ -16,10 +16,7 @@ export default async function AppEditPage(props: AppEditPageProps) { const params = await props.params; const session = await auth(); - if ( - !session?.user.permissions.includes("board-modify-all") || - !session?.user.permissions.includes("app-modify-all") - ) { + if (!session?.user.permissions.includes("app-modify-all")) { notFound(); } const app = await api.app.byId({ id: params.id }); diff --git a/apps/nextjs/src/app/[locale]/manage/apps/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/new/page.tsx index 989273d7c..ace72f5fa 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/new/page.tsx @@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; export default async function AppNewPage() { const session = await auth(); - if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) { + if (!session?.user.permissions.includes("app-create")) { notFound(); } diff --git a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx index 083951b9c..e581d55a9 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx @@ -1,18 +1,19 @@ import { Fragment } from "react"; -import { notFound, redirect } from "next/navigation"; -import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core"; +import { redirect } from "next/navigation"; +import { ActionIcon, ActionIconGroup, Anchor, Avatar, Text } from "@mantine/core"; import { IconBox, IconPencil } from "@tabler/icons-react"; import { z } from "zod/v4"; import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; +import { getSafeAppHref } from "@homarr/common"; import type { inferSearchParamsFromSchema } from "@homarr/common/types"; -import { getI18n, getScopedI18n } from "@homarr/translation/server"; +import { 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 { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { NoResults } from "~/components/no-results"; import { AppDeleteButton } from "./_app-delete-button"; @@ -29,24 +30,38 @@ interface AppsPageProps { export default async function AppsPage(props: AppsPageProps) { const session = await auth(); - - if (!session) { - redirect("/auth/login"); - } - if (!session.user.permissions.includes("board-modify-all")) { - notFound(); - } + if (!session) redirect("/auth/login"); const searchParams = searchParamsSchema.parse(await props.searchParams); - const { items: apps, totalCount } = await api.app.getPaginated(searchParams); const t = await getScopedI18n("app"); - const canCreate = session.user.permissions.includes("app-create"); + const canModify = session.user.permissions.includes("app-modify-all"); + const canDelete = session.user.permissions.includes("app-full-all"); + const hasSearch = Boolean(searchParams.search?.trim()); - return ( - + ) : ( + + ); + + const page = ( + @@ -56,101 +71,94 @@ export default async function AppsPage(props: AppsPageProps) { ) : undefined } - toolbar={} - footer={} + toolbar={ + + } + footer={ + totalCount > searchParams.pageSize ? ( + + ) : undefined + } floatingPrimaryAction={canCreate} > - {apps.length === 0 && } - {apps.length > 0 && ( - - - {apps.map((app) => ( - - ))} - - - )} - + {apps.map((app) => ( + + ))} + ); + + return apps.length > 0 ? {page} : page; } -interface AppCardProps { - app: RouterOutputs["app"]["all"][number]; +interface AppItemProps { + app: RouterOutputs["app"]["getPaginated"]["items"][number]; + canModify: boolean; + canDelete: boolean; } -const AppCard = async ({ app }: AppCardProps) => { +const AppItem = async ({ app, canModify, canDelete }: AppItemProps) => { const t = await getScopedI18n("app"); - const session = await auth(); + const descriptionLines = app.description?.split("\n"); + const safeHref = getSafeAppHref(app.href); return ( - - - - } + title={ + + {app.name} + + } + description={ + descriptionLines ? ( + + {descriptionLines.map((line, index) => ( + + {line} + {index < descriptionLines.length - 1 &&
} +
+ ))} +
+ ) : undefined + } + metadata={ + safeHref ? ( + - - - {app.name} - - {app.description && ( - - {app.description.split("\n").map((line, index) => ( - - {line} -
-
- ))} -
- )} - {app.href && ( - - {app.href} - - )} -
-
- + style={{ wordBreak: "break-all" }} + > + {app.href} + + ) : undefined + } + actions={ + canModify || canDelete ? ( - {session?.user.permissions.includes("app-modify-all") && ( + {canModify && ( - + )} - {session?.user.permissions.includes("app-full-all") && } + {canDelete && } - -
-
- ); -}; - -const AppNoResults = async () => { - const t = await getI18n(); - const session = await auth(); - - return ( - ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx index 5e9642e32..77fed0399 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx @@ -1,38 +1,23 @@ "use client"; -import { Button, Menu } from "@mantine/core"; -import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react"; +import { Button } from "@mantine/core"; +import { IconCategoryPlus } from "@tabler/icons-react"; import { useModalAction } from "@homarr/modals"; -import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection"; +import { AddBoardModal } from "@homarr/modals-collection"; import { useI18n } from "@homarr/translation/client"; import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; export const CreateBoardButton = () => { const t = useI18n(); - const { openModal: openAddModal } = useModalAction(AddBoardModal); - const { openModal: openImportModal } = useModalAction(ImportBoardModal); + const { openModal } = useModalAction(AddBoardModal); return ( - - - - - - - - }> - {t("board.action.oldImport.label")} - - - - + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx index b7f994c41..7db861c6d 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx @@ -24,10 +24,11 @@ import { import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; +import { constructBoardPermissions } from "@homarr/auth/shared"; import { getScopedI18n } from "@homarr/translation/server"; import { Link, UserAvatar } from "@homarr/ui"; -import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; +import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail"; import { TourTarget } from "~/components/layout/header/tour-target"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { NoResults } from "~/components/no-results"; @@ -37,7 +38,7 @@ import { CreateBoardButton } from "./_components/create-board-button"; export default async function ManageBoardsPage() { const t = await getScopedI18n("management.page.board"); const session = await auth(); - const boards = await api.board.getAllBoards(); + const boards = await api.board.getManageOverview(); const canCreateBoards = session?.user.permissions.includes("board-create"); return ( @@ -52,13 +53,31 @@ export default async function ManageBoardsPage() { } floatingPrimaryAction={canCreateBoards} > - {boards.length === 0 && } + {boards.length === 0 && ( + + )} {boards.length > 0 && ( {boards.map((board) => ( - + ))} @@ -69,48 +88,60 @@ export default async function ManageBoardsPage() { } interface BoardCardProps { - board: RouterOutputs["board"]["getAllBoards"][number]; + board: RouterOutputs["board"]["getManageOverview"][number]; + isMenuVisible: boolean; + labels: { + visibility: string; + preview: string; + home: string; + homeTooltip: string; + mobileHome: string; + mobileHomeTooltip: string; + open: string; + settings: string; + }; } -const BoardCard = async ({ board }: BoardCardProps) => { - const t = await getScopedI18n("management.page.board"); - const { hasChangeAccess: isMenuVisible } = await getBoardPermissionsAsync(board); - const visibility = board.isPublic ? "public" : "private"; +const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => { const VisibilityIcon = board.isPublic ? IconWorld : IconLock; return ( - + + + + - - - + + + - {board.name} + + {board.name} + - + {board.isHome && ( - + }> - {t("action.setHomeBoard.badge.label")} + {labels.home} )} {board.isMobileHome && ( - + }> - {t("action.setMobileHomeBoard.badge.label")} + {labels.mobileHome} )} {board.creator && ( - + - {board.creator.name} - + )} @@ -123,9 +154,10 @@ const BoardCard = async ({ board }: BoardCardProps) => { component={Link} href={`/boards/${board.name}`} variant="default" + h={44} fullWidth > - {t("action.open.label")} + {labels.open} {isMenuVisible && ( @@ -133,8 +165,8 @@ const BoardCard = async ({ board }: BoardCardProps) => { diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx index 0e08fff5d..e0b95c96f 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx @@ -1,6 +1,5 @@ "use client"; -import { useRouter } from "next/navigation"; import { ActionIcon } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; @@ -11,13 +10,12 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica import { useScopedI18n } from "@homarr/translation/client"; interface DeleteIntegrationActionButtonProps { - count: number; integration: { id: string; name: string }; } -export const DeleteIntegrationActionButton = ({ count, integration }: DeleteIntegrationActionButtonProps) => { +export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => { const t = useScopedI18n("integration.page.delete"); - const router = useRouter(); + const tList = useScopedI18n("integration.page.list.action"); const { openConfirmModal } = useConfirmModal(); const utils = clientApi.useUtils(); const { mutateAsync, isPending } = clientApi.integration.delete.useMutation(); @@ -27,6 +25,7 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte loading={isPending} variant="subtle" color="red" + size={44} onClick={() => { openConfirmModal({ title: t("title"), @@ -40,9 +39,6 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte title: t("notification.success.title"), message: t("notification.success.message"), }); - if (count === 1) { - router.replace("/manage/integrations"); - } void revalidatePathActionAsync("/manage/integrations"); void utils.integration.invalidate(); }, @@ -57,7 +53,7 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte }, }); }} - aria-label={t("title")} + aria-label={tList("delete", { name: integration.name })} > diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.module.css b/apps/nextjs/src/app/[locale]/manage/integrations/page.module.css deleted file mode 100644 index 551deb5ab..000000000 --- a/apps/nextjs/src/app/[locale]/manage/integrations/page.module.css +++ /dev/null @@ -1,34 +0,0 @@ -.root { - border-radius: var(--mantine-radius-lg); - background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); -} - -.item { - background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); - border: 1px solid transparent; - position: relative; - z-index: 0; - transition: background-color 150ms ease; - overflow: hidden; - - &[data-first="true"] { - border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0; - } - - &[data-last="true"] { - border-radius: 0 0 var(--mantine-radius-lg) var(--mantine-radius-lg); - } - - &[data-active] { - z-index: 1; - background-color: var(--mantine-color-body); - border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); - border-radius: var(--mantine-radius-lg); - } -} - -.chevron { - &[data-rotate] { - transform: rotate(180deg); - } -} diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx index 0eeb25c1a..785137023 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx @@ -1,66 +1,80 @@ -import { Fragment } from "react"; import { redirect } from "next/navigation"; -import { - AccordionControl, - AccordionItem, - AccordionPanel, - ActionIcon, - ActionIconGroup, - Anchor, - Divider, - Group, - Stack, - Table, - TableTbody, - TableTd, - TableTh, - TableThead, - TableTr, - Text, -} from "@mantine/core"; +import { ActionIcon, ActionIconGroup, Anchor, Badge, Text } from "@mantine/core"; import { IconPencil, IconPlugX } from "@tabler/icons-react"; +import { z } from "zod/v4"; import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; -import { objectEntries } from "@homarr/common"; -import type { IntegrationKind } from "@homarr/definitions"; +import { getSafeApplicationUrl } from "@homarr/common"; import { getIntegrationName } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; -import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui"; +import { IntegrationAvatar, Link, SearchInput } from "@homarr/ui"; import { TourTarget } from "~/components/layout/header/tour-target"; -import { ManagePageLayout } from "~/components/manage/manage-page-layout"; +import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { NoResults } from "~/components/no-results"; -import { ActiveTabAccordion } from "../../../../components/active-tab-accordion"; import { DeleteIntegrationActionButton } from "./_integration-buttons"; -import classes from "./page.module.css"; + +const searchParamsSchema = z.object({ search: z.string().optional() }); interface IntegrationsPageProps { - searchParams: Promise<{ - tab?: IntegrationKind; - }>; + searchParams: Promise>; } export default async function IntegrationsPage(props: IntegrationsPageProps) { - const searchParams = await props.searchParams; const session = await auth(); + if (!session) redirect("/auth/login"); - if (!session) { - redirect("/auth/login"); - } - + const searchParams = searchParamsSchema.parse(await props.searchParams); const integrations = await api.integration.all(); const t = await getScopedI18n("integration"); + const canCreate = session.user.permissions.includes("integration-create"); + const hasGlobalFullAccess = session.user.permissions.includes("integration-full-all"); + const query = searchParams.search?.trim().toLocaleLowerCase() ?? ""; + const filteredIntegrations = integrations + .filter((integration) => { + if (!query) return true; + return [integration.name, getIntegrationName(integration.kind), integration.url].some((value) => + value.toLocaleLowerCase().includes(query), + ); + }) + .toSorted( + (integrationA, integrationB) => + getIntegrationName(integrationA.kind).localeCompare(getIntegrationName(integrationB.kind)) || + integrationA.name.localeCompare(integrationB.name), + ); + const hasSearch = query.length > 0; - const canCreateIntegrations = session.user.permissions.includes("integration-create"); - - return ( - + ) : ( + + ) + } primaryAction={ - canCreateIntegrations ? ( + canCreate ? ( {t("action.create")} @@ -68,128 +82,83 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) { ) : undefined } - floatingPrimaryAction={canCreateIntegrations} - > - - - - - ); -} - -interface IntegrationListProps { - integrations: RouterOutputs["integration"]["all"]; - activeTab?: IntegrationKind; -} - -const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => { - const t = await getScopedI18n("integration"); - const session = await auth(); - const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false; - - if (integrations.length === 0) { - return ; - } - - const groupedIntegrations = integrations.reduce( - (acc, integration) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!acc[integration.kind]) { - acc[integration.kind] = []; + toolbar={ + } - - acc[integration.kind].push(integration); - - return acc; - }, - {} as Record, + floatingPrimaryAction={canCreate} + > + {filteredIntegrations.map((integration) => ( + + ))} + ); - const entries = objectEntries(groupedIntegrations); + return filteredIntegrations.length > 0 ? {page} : page; +} + +interface IntegrationItemProps { + integration: RouterOutputs["integration"]["all"][number]; + canManage: boolean; +} + +const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps) => { + const t = await getScopedI18n("integration"); + const kindName = getIntegrationName(integration.kind); + const safeUrl = getSafeApplicationUrl(integration.url); return ( - - {entries.map(([kind, kindIntegrations], index) => ( - - }> - - {getIntegrationName(kind)} - - - - - - - - {t("field.name.label")} - {t("field.url.label")} - - - - - {kindIntegrations.map((integration) => ( - - {integration.name} - - - {integration.url} - - - - - {(hasFullAccess || integration.permissions.hasFullAccess) && ( - - - - - - - )} - - - - ))} - -
- - - {kindIntegrations.map((integration, integrationIndex) => ( - - {integrationIndex !== 0 && } - - - {integration.name} - {(hasFullAccess || integration.permissions.hasFullAccess) && ( - - - - - - - )} - - - {integration.url} - - - - ))} - -
-
- ))} -
+ } + title={ + + {integration.name} + + } + badges={ + + {kindName} + + } + metadata={ + safeUrl ? ( + + {integration.url} + + ) : undefined + } + actions={ + canManage ? ( + + + + + + + ) : undefined + } + /> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/medias/page.tsx b/apps/nextjs/src/app/[locale]/manage/medias/page.tsx index 0072e9ed3..a9c40dec0 100644 --- a/apps/nextjs/src/app/[locale]/manage/medias/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/medias/page.tsx @@ -71,7 +71,11 @@ export default async function MediaListPage(props: MediaListPageProps) { } toolbar={ - + {session.user.permissions.includes("media-view-all") && ( )} diff --git a/apps/nextjs/src/app/[locale]/manage/search-engines/_search-engine-delete-button.tsx b/apps/nextjs/src/app/[locale]/manage/search-engines/_search-engine-delete-button.tsx index 6857c4714..f92388c03 100644 --- a/apps/nextjs/src/app/[locale]/manage/search-engines/_search-engine-delete-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/search-engines/_search-engine-delete-button.tsx @@ -17,6 +17,7 @@ interface SearchEngineDeleteButtonProps { export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => { const t = useScopedI18n("search.engine.page.delete"); + const tList = useScopedI18n("search.engine.page.list.action"); const { openConfirmModal } = useConfirmModal(); const { mutate, isPending } = clientApi.searchEngine.delete.useMutation(); @@ -50,7 +51,14 @@ export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteBut }, [searchEngine, mutate, t, openConfirmModal]); return ( - + ); diff --git a/apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx b/apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx index fd4066d64..d336cfd6a 100644 --- a/apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx @@ -1,16 +1,17 @@ import { redirect } from "next/navigation"; -import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core"; +import { ActionIcon, ActionIconGroup, Anchor, Avatar, Badge, Text } from "@mantine/core"; import { IconPencil, IconSearch } from "@tabler/icons-react"; import { z } from "zod/v4"; import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; +import { getSafeApplicationUrl } from "@homarr/common"; import type { inferSearchParamsFromSchema } from "@homarr/common/types"; -import { getI18n, getScopedI18n } from "@homarr/translation/server"; +import { getScopedI18n } from "@homarr/translation/server"; import { Link, SearchInput, TablePagination } from "@homarr/ui"; -import { ManagePageLayout } from "~/components/manage/manage-page-layout"; +import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { NoResults } from "~/components/no-results"; import { SearchEngineDeleteButton } from "./_search-engine-delete-button"; @@ -27,123 +28,145 @@ interface SearchEnginesPageProps { export default async function SearchEnginesPage(props: SearchEnginesPageProps) { const session = await auth(); - - if (!session) { - redirect("/auth/login"); - } + if (!session) redirect("/auth/login"); const searchParams = searchParamsSchema.parse(await props.searchParams); const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams); - - const tEngine = await getScopedI18n("search.engine"); - + const t = await getScopedI18n("search.engine"); const canCreate = session.user.permissions.includes("search-engine-create"); + const canModify = session.user.permissions.includes("search-engine-modify-all"); + const canDelete = session.user.permissions.includes("search-engine-full-all"); + const hasSearch = Boolean(searchParams.search?.trim()); return ( - + ) : ( + + ) + } primaryAction={ canCreate ? ( - {tEngine("page.create.title")} + {t("page.create.title")} ) : undefined } - toolbar={} - footer={} + toolbar={ + + } + footer={ + totalCount > searchParams.pageSize ? ( + + ) : undefined + } floatingPrimaryAction={canCreate} > - {searchEngines.length === 0 && } - {searchEngines.length > 0 && ( - - {searchEngines.map((searchEngine) => ( - - ))} - - )} - + {searchEngines.map((searchEngine) => ( + + ))} + ); } -interface SearchEngineCardProps { +interface SearchEngineItemProps { searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number]; + canModify: boolean; + canDelete: boolean; } -const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => { +const SearchEngineItem = async ({ searchEngine, canModify, canDelete }: SearchEngineItemProps) => { const t = await getScopedI18n("search.engine"); - const session = await auth(); + const isIntegration = searchEngine.type === "fromIntegration"; + const previewUrl = + searchEngine.type === "generic" && searchEngine.urlTemplate !== null + ? getSafeApplicationUrl(searchEngine.urlTemplate.replace("%s", "test")) + : undefined; return ( - - - - + } + title={ + + {searchEngine.name} + + } + badges={ + + {t(`page.list.type.${searchEngine.type}`)} + + } + description={ + searchEngine.description ? ( + + {searchEngine.description} + + ) : undefined + } + metadata={ + previewUrl ? ( + - - - {searchEngine.name} - - {searchEngine.description && ( - - {searchEngine.description} - - )} - {searchEngine.type === "generic" && searchEngine.urlTemplate !== null && ( - - {searchEngine.urlTemplate} - - )} - {searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && ( - - {t("page.list.interactive")} - - )} - - - + style={{ wordBreak: "break-all" }} + > + {searchEngine.urlTemplate} + + ) : undefined + } + actions={ + canModify || canDelete ? ( - {session?.user.permissions.includes("search-engine-modify-all") && ( + {canModify && ( - + )} - {session?.user.permissions.includes("search-engine-full-all") && ( - - )} + {canDelete && } - - - - ); -}; - -const SearchEngineNoResults = async () => { - const t = await getI18n(); - const session = await auth(); - - return ( - ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx index c46c368ac..878d07160 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx @@ -66,7 +66,11 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) { )} - + {canManageMembers && !isReserved && ( , importedKeyHex: const isOnboardingActiveAsync = async (): Promise => { const onboardingRow = await db.query.onboarding.findFirst(); if (!onboardingRow) return false; - return onboardingRow.step === "start"; + return normalizeOnboardingStep(onboardingRow.step) === "start"; }; export async function POST(req: Request) { const session = await auth(); const isAdmin = session?.user.permissions.includes("admin") ?? false; - const isOnboarding = !isAdmin && (await isOnboardingActiveAsync()); + const hasOnboardingClaim = await isClaimOnlyOnboardingAccessAllowedAsync( + db, + getOnboardingClaimTokenFromCookieHeader(req.headers.get("cookie")), + ); + const isOnboarding = !isAdmin && hasOnboardingClaim && (await isOnboardingActiveAsync()); if (!isAdmin && !isOnboarding) { return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); diff --git a/apps/nextjs/src/app/api/onboarding/claim/route.ts b/apps/nextjs/src/app/api/onboarding/claim/route.ts new file mode 100644 index 000000000..df547d39a --- /dev/null +++ b/apps/nextjs/src/app/api/onboarding/claim/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; + +import { + claimOnboardingAsync, + getOnboardingClaimTokenFromCookieHeader, + onboardingClaimCookieName, +} from "@homarr/api/onboarding-claim"; +import { auth } from "@homarr/auth/next"; +import { db } from "@homarr/db"; + +const noStoreHeaders = { "Cache-Control": "no-store" }; + +export async function POST(request: Request) { + const fetchSite = request.headers.get("sec-fetch-site"); + if (fetchSite && fetchSite !== "same-origin" && fetchSite !== "none") { + return NextResponse.json({ error: "Cross-site onboarding claims are not allowed." }, { status: 403 }); + } + + const session = await auth(); + const result = await claimOnboardingAsync(db, { + currentToken: getOnboardingClaimTokenFromCookieHeader(request.headers.get("cookie")), + force: session?.user.permissions.includes("admin") ?? false, + }); + if (result.status === "finished") { + const response = NextResponse.json({ error: "Onboarding is already complete." }, { status: 409 }); + response.cookies.delete(onboardingClaimCookieName); + return response; + } + if (result.status === "forbidden") { + return NextResponse.json({ error: "Administrator access is required." }, { status: 403 }); + } + if (result.status === "locked") { + return NextResponse.json( + { error: "Another browser has already claimed onboarding.", expiresAt: result.expiresAt }, + { status: 423, headers: noStoreHeaders }, + ); + } + if (result.status !== "issued" && result.status !== "active") { + return NextResponse.json({ error: "Onboarding could not be claimed." }, { status: 409 }); + } + + const forwardedProtocol = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim(); + const secure = forwardedProtocol === "https" || new URL(request.url).protocol === "https:"; + const response = NextResponse.json( + { status: result.status, expiresAt: result.expiresAt }, + { headers: noStoreHeaders }, + ); + response.cookies.set(onboardingClaimCookieName, result.token, { + httpOnly: true, + sameSite: "strict", + secure, + path: "/", + expires: new Date(result.expiresAt), + }); + return response; +} diff --git a/apps/nextjs/src/components/board/board-layout-thumbnail.module.css b/apps/nextjs/src/components/board/board-layout-thumbnail.module.css new file mode 100644 index 000000000..41909b4db --- /dev/null +++ b/apps/nextjs/src/components/board/board-layout-thumbnail.module.css @@ -0,0 +1,68 @@ +.canvas { + min-height: 8rem; + aspect-ratio: 16 / 7; + overflow: hidden; + color: var(--mantine-color-dimmed); + background: + radial-gradient( + circle at 15% 10%, + color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent), + transparent 35% + ), + var(--mantine-color-body); +} + +.lanes { + display: grid; + height: 100%; + gap: rem(8px); + padding: rem(12px); +} + +.lane { + display: grid; + min-width: 0; + gap: rem(3px); + padding: rem(4px); + overflow: hidden; + border: rem(1px) solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-sm); + background-image: + linear-gradient( + to right, + color-mix(in srgb, var(--mantine-color-dimmed) 8%, transparent) rem(1px), + transparent rem(1px) + ), + linear-gradient( + to bottom, + color-mix(in srgb, var(--mantine-color-dimmed) 8%, transparent) rem(1px), + transparent rem(1px) + ); + background-size: rem(12px) rem(12px); +} + +.lane[data-lane="left"], +.lane[data-lane="right"] { + background-color: color-mix(in srgb, var(--mantine-primary-color-light) 40%, transparent); +} + +.tile { + min-width: 0; + min-height: 0; + border: rem(1px) solid color-mix(in srgb, var(--mantine-primary-color-filled) 32%, transparent); + border-radius: max(rem(2px), calc(var(--mantine-radius-default) / 2)); + background: color-mix(in srgb, var(--mantine-primary-color-filled) 18%, var(--mantine-color-body)); + box-shadow: 0 rem(1px) rem(3px) rgb(0 0 0 / 8%); +} + +.container { + border-style: dashed; + border-color: color-mix(in srgb, var(--mantine-color-teal-filled) 45%, transparent); + background: color-mix(in srgb, var(--mantine-color-teal-light) 55%, var(--mantine-color-body)); +} + +@media (max-width: $mantine-breakpoint-xs) { + .canvas { + min-height: 7rem; + } +} diff --git a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx new file mode 100644 index 000000000..e8cdb7a0a --- /dev/null +++ b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx @@ -0,0 +1,91 @@ +import { Box, Center } from "@mantine/core"; +import { IconLayoutGrid } from "@tabler/icons-react"; + +import type { BoardPreviewData } from "@homarr/boards/layout-preview"; +import { projectBoardLayout } from "@homarr/boards/layout-preview"; +import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions"; + +import classes from "./board-layout-thumbnail.module.css"; + +interface BoardLayoutThumbnailProps { + preview: BoardPreviewData | null; + label: string; +} + +const maxThumbnailRows = 12; + +export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailProps) => { + const layout = preview?.layouts.at(0); + if (!preview || !layout) { + return ( +
+ +
+ ); + } + + const elements = projectBoardLayout(preview, layout, layout); + const roots = preview.sections.filter((section) => section.kind === "empty"); + const lanes = boardLanes.flatMap((lane) => { + const root = roots.find((section) => getRootSectionLane(section.xOffset) === lane); + const columnCount = getBoardLaneColumnCount(layout, lane); + return root && columnCount > 0 ? [{ lane, root, columnCount }] : []; + }); + if (lanes.length === 0) { + return ( +
+ +
+ ); + } + + return ( + +
`${columnCount}fr`).join(" ") }} + > + {lanes.map(({ lane, root, columnCount }) => { + const laneElements = elements.filter( + (element) => + element.sectionId === root.id && + element.xOffset >= 0 && + element.xOffset < columnCount && + element.yOffset >= 0 && + element.yOffset < maxThumbnailRows && + element.width > 0 && + element.height > 0, + ); + const rowCount = Math.min( + maxThumbnailRows, + Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)), + ); + + return ( +
+ {laneElements.map((element) => ( + + ))} +
+ ); + })} +
+
+ ); +}; 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 0bc6c296a..8688f68d3 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -196,6 +196,7 @@ const ItemSelectModalContent = ({ search={search} onSearchChange={setSearch} placeholder={`${t("item.create.search")}...`} + ariaLabel={t("item.create.search")} onSearchKeyDown={(event) => { if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) { const [item] = filteredItems; diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts index dde74820f..b63015838 100644 --- a/apps/nextjs/src/components/integration/integration-grid-shared.ts +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -1,76 +1,6 @@ -import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import { - featuredIntegrations, - getIntegrationName, - hiddenFromOnboarding, - integrationDefs, - integrationKinds, - getWidgetKindsForIntegration, -} from "@homarr/definitions"; - -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", - beszel: "integration.category.beszel", - search: "integration.category.search", - mediaTranscoding: "integration.category.mediaTranscoding", - networkController: "integration.category.networkController", - notifications: "integration.category.notifications", - firewall: "integration.category.firewall", - photoService: "integration.category.photoService", - notes: "integration.category.notes", - mediaMonitoring: "integration.category.mediaMonitoring", - speedtest: "integration.category.speedtest", - analytics: "integration.category.analytics", - vpn: "integration.category.vpn", - documents: "integration.category.documents", - mediaLibrary: "integration.category.mediaLibrary", - uptimeMonitoring: "integration.category.uptimeMonitoring", -}; - -export interface IntegrationGridItem { - kind: IntegrationKind; - name: string; - categories: string[]; - widgets: WidgetKind[]; -} - -export const buildSortedIntegrations = ( - options: { enableMockIntegration?: boolean; onboarding?: boolean } = {}, -): IntegrationGridItem[] => { - return integrationKinds - .filter((kind) => { - if (options.onboarding && hiddenFromOnboarding.has(kind)) return false; - if (options.onboarding && getWidgetKindsForIntegration(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: getWidgetKindsForIntegration(kind), - })) - .toSorted((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())); +export { + buildSortedIntegrations, + categoryTranslationKeys, + filterIntegrations, +} from "@homarr/ui/integration-select-grid"; +export type { IntegrationGridItem } from "@homarr/ui/integration-select-grid"; diff --git a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx index cbd347ba1..ab696a6ff 100644 --- a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx +++ b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx @@ -1,140 +1,2 @@ -"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")} -
- )} -
- ); -}; +export { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid"; +export type { IntegrationMultiSelectGridProps } from "@homarr/ui/integration-select-grid"; diff --git a/apps/nextjs/src/components/integration/integration-select-grid.tsx b/apps/nextjs/src/components/integration/integration-select-grid.tsx index f4905dd2a..f0dcf2a75 100644 --- a/apps/nextjs/src/components/integration/integration-select-grid.tsx +++ b/apps/nextjs/src/components/integration/integration-select-grid.tsx @@ -1,90 +1,2 @@ -"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")} -
- )} -
- ); -}; +export { IntegrationSelectGrid } from "@homarr/ui/integration-select-grid"; +export type { IntegrationSelectGridProps } from "@homarr/ui/integration-select-grid"; diff --git a/apps/nextjs/src/components/manage/manage-collection.tsx b/apps/nextjs/src/components/manage/manage-collection.tsx new file mode 100644 index 000000000..16ce87131 --- /dev/null +++ b/apps/nextjs/src/components/manage/manage-collection.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from "react"; +import { Box, Group, Paper, Stack } from "@mantine/core"; + +import type { ManagePageLayoutProps } from "./manage-page-layout"; +import { ManagePageLayout } from "./manage-page-layout"; + +export interface ManageCollectionPageProps extends Omit { + ariaLabel: string; + itemCount: number; + emptyState: ReactNode; + children: ReactNode; +} + +export const ManageCollectionPage = ({ + ariaLabel, + itemCount, + emptyState, + children, + ...layoutProps +}: ManageCollectionPageProps) => ( + + {itemCount === 0 ? ( + emptyState + ) : ( + + {children} + + )} + +); + +export interface ManageCollectionItemProps { + leading?: ReactNode; + title: ReactNode; + description?: ReactNode; + metadata?: ReactNode; + badges?: ReactNode; + actions?: ReactNode; +} + +export const ManageCollectionItem = ({ + leading, + title, + description, + metadata, + badges, + actions, +}: ManageCollectionItemProps) => ( + + + {leading && {leading}} + + + {title} + {badges} + + {description} + {metadata} + + {actions && ( + + {actions} + + )} + + +); diff --git a/apps/nextjs/src/components/manage/manage-page-layout.tsx b/apps/nextjs/src/components/manage/manage-page-layout.tsx index d8655c58c..9a90328c2 100644 --- a/apps/nextjs/src/components/manage/manage-page-layout.tsx +++ b/apps/nextjs/src/components/manage/manage-page-layout.tsx @@ -6,7 +6,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { ManageContainer } from "./manage-container"; import { MANAGE_FLOATING_ACTION_BOTTOM_OFFSET } from "./manage-page.constants"; -interface ManagePageLayoutProps { +export interface ManagePageLayoutProps { title: ReactNode; primaryAction?: ReactNode; toolbar?: ReactNode; diff --git a/apps/nextjs/src/components/no-results.tsx b/apps/nextjs/src/components/no-results.tsx index 9506bafa4..c60b3d0b9 100644 --- a/apps/nextjs/src/components/no-results.tsx +++ b/apps/nextjs/src/components/no-results.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { Anchor, Card, Stack, Text } from "@mantine/core"; import type { TablerIcon } from "@homarr/ui"; @@ -5,6 +6,7 @@ import type { TablerIcon } from "@homarr/ui"; interface NoResultsProps { icon: TablerIcon; title: string; + description?: ReactNode; action?: { label: string; href: string; @@ -12,7 +14,7 @@ interface NoResultsProps { }; } -export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => { +export const NoResults = ({ icon: Icon, title, description, action }: NoResultsProps) => { return ( @@ -20,6 +22,11 @@ export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => { {title} + {description && ( + + {description} + + )} {action && !action.hidden && {action.label}} diff --git a/apps/nextjs/src/proxy.spec.ts b/apps/nextjs/src/proxy.spec.ts index 2b9101bde..f1ce7681c 100644 --- a/apps/nextjs/src/proxy.spec.ts +++ b/apps/nextjs/src/proxy.spec.ts @@ -62,6 +62,16 @@ describe("locale and onboarding proxy", () => { expect(mocks.getDefaultLocale).toHaveBeenCalledOnce(); }); + it("allows administrator sign-in while onboarding is unfinished", async () => { + mocks.getOnboardingStep.mockResolvedValue("setup"); + const { proxy } = await loadProxy(); + + const response = await proxy(createRequest("/en/auth/login")); + + expect(response.status).toBe(200); + expect(mocks.getOnboardingStep).not.toHaveBeenCalled(); + }); + it("uses the configured default locale for a missing or invalid locale cookie", async () => { mocks.getDefaultLocale.mockResolvedValue("de"); const { proxy } = await loadProxy(); diff --git a/apps/nextjs/src/proxy.ts b/apps/nextjs/src/proxy.ts index 1bf12ba49..d89c785ee 100644 --- a/apps/nextjs/src/proxy.ts +++ b/apps/nextjs/src/proxy.ts @@ -42,8 +42,9 @@ const getDefaultLocaleDedupedAsync = () => { export async function proxy(request: NextRequest) { // Redirect to onboarding if it's not finished yet const pathname = request.nextUrl.pathname; + const isOnboardingAccessRoute = /\/(?:init|auth\/login)\/?$/.test(pathname); - if (!isOnboardingFinished && !pathname.endsWith("/init")) { + if (!isOnboardingFinished && !isOnboardingAccessRoute) { const currentOnboardingStep = await getOnboardingStepDedupedAsync(); if (currentOnboardingStep !== "finish") { return NextResponse.redirect(new URL("/init", request.url)); diff --git a/e2e/onboarding.spec.ts b/e2e/onboarding.spec.ts index 39f473c20..5a7a69cf9 100644 --- a/e2e/onboarding.spec.ts +++ b/e2e/onboarding.spec.ts @@ -1,12 +1,102 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; + import { chromium } from "@playwright/test"; -import { describe, test } from "vitest"; +import type { Page } from "@playwright/test"; +import { describe, expect, test } from "vitest"; import { OnboardingActions } from "./shared/actions/onboarding-actions"; import { OnboardingAssertions } from "./shared/assertions/onboarding-assertions"; import { createHomarrContainer } from "./shared/create-homarr-container"; import { createSqliteDbFileAsync } from "./shared/e2e-db"; +const captureScreenshotAsync = async (page: Page, name: string) => { + const directory = process.env.HOMARR_E2E_SCREENSHOT_DIR?.trim(); + if (!directory) return; + await mkdir(directory, { recursive: true }); + await page.screenshot({ path: path.join(directory, `onboarding-${name}.png`), fullPage: true }); +}; + describe("Onboarding", () => { + test.each([ + { name: "mobile", width: 320, height: 740 }, + { name: "tablet", width: 768, height: 1024 }, + { name: "desktop", width: 1440, height: 900 }, + ])("Onboarding is usable at the $name viewport", async ({ name, width, height }) => { + const { localMountPath } = await createSqliteDbFileAsync(); + const homarrContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start(); + const browser = await chromium.launch(); + const context = await browser.newContext({ viewport: { width, height }, reducedMotion: "reduce" }); + const page = await context.newPage(); + + try { + await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/init`); + await page.getByRole("heading", { name: "Make home feel organized." }).waitFor(); + const start = page.getByRole("button", { name: "Get started" }); + await start.focus(); + await start.press("Enter"); + await page.getByRole("heading", { name: "Create your administrator" }).waitFor(); + + await page.getByLabel("Administrator username").fill("admin"); + await page.getByLabel("Password", { exact: true }).fill("Comp(exP4sswOrd"); + await page.getByLabel("Confirm password").fill("Comp(exP4sswOrd"); + await page.locator("css=button[type='submit']").click(); + await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor({ timeout: 30_000 }); + + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), + ).toBe(true); + await captureScreenshotAsync(page, `${name}-studio`); + + const activeSection = page.locator('[aria-current="step"]'); + await activeSection.focus(); + await activeSection.press("ArrowLeft"); + await page.getByRole("heading", { name: "One board, built around your services" }).waitFor(); + expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true); + await activeSection.press("ArrowRight"); + await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor(); + expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true); + await activeSection.press("End"); + await page.getByRole("heading", { name: "One board, built around your services" }).waitFor(); + await activeSection.press("Home"); + await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor(); + expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true); + + const sections = [ + ["Discover", "See what this installation can actually reach"], + ["Connect", "Connect what makes the board useful"], + ["Board", "Shape the board before it is built"], + ["Extend", "Add intelligence when it helps"], + ] as const; + for (const [section, heading] of sections) { + await page.getByRole("button", { name: new RegExp(`^${section}`) }).click(); + await page.getByRole("heading", { name: heading }).waitFor(); + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), + ).toBe(true); + } + + await page.getByRole("button", { name: /^Connect/ }).click(); + const sonarr = page.locator('button[aria-label="Sonarr"]'); + await sonarr.click(); + await page.getByLabel("Service URL").waitFor(); + await captureScreenshotAsync(page, `${name}-integration-details`); + await sonarr.click(); + await page.getByLabel("Service URL").waitFor({ state: "detached" }); + + await activeSection.focus(); + await activeSection.press("End"); + await page.getByRole("heading", { name: "One board, built around your services" }).waitFor(); + await captureScreenshotAsync(page, `${name}-review`); + + await page.getByRole("button", { name: "Build my board" }).click(); + await page.getByRole("heading", { name: "Your first board is ready." }).waitFor({ timeout: 30_000 }); + await captureScreenshotAsync(page, `${name}-finish`); + } finally { + await Promise.all([browser.close(), homarrContainer.stop()]); + } + }, 90_000); + test("Credentials onboarding should be successful", async () => { // Arrange const { db, localMountPath } = await createSqliteDbFileAsync(); @@ -25,7 +115,15 @@ describe("Onboarding", () => { try { // Act await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`); - await actions.startOnboardingAsync("scratch"); + await actions.startOnboardingAsync(); + + const competingContext = await browser.newContext(); + const claimResponse = await competingContext.request.post( + `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/api/onboarding/claim`, + ); + expect(claimResponse.status()).toBe(423); + await competingContext.close(); + await actions.processUserStepAsync({ username: "admin", password: "Comp(exP4sswOrd", @@ -43,7 +141,7 @@ describe("Onboarding", () => { } }, 60_000); - test("External provider onboarding setup should be successful", async () => { + test("External provider onboarding requires sign-in before privileged setup", async () => { // Arrange const { db, localMountPath } = await createSqliteDbFileAsync(); const homarrContainer = await createHomarrContainer({ @@ -69,17 +167,18 @@ describe("Onboarding", () => { try { // Act await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`); - await actions.startOnboardingAsync("scratch"); + await actions.startOnboardingAsync(); await actions.processExternalGroupStepAsync({ name: externalGroupName, }); - await actions.processSettingsStepAsync(); - await actions.processIntegrationsStepAsync(); // Assert - await assertions.assertFinishStepVisibleAsync(); + await page.getByRole("heading", { name: "Administrator sign-in required" }).waitFor(); + expect(await page.getByRole("link", { name: "Sign in" }).getAttribute("href")).toBe( + "/auth/login?callbackUrl=%2Finit", + ); await assertions.assertExternalGroupInsertedAsync(externalGroupName); - await assertions.assertDbOnboardingStepAsync("finish"); + await assertions.assertDbOnboardingStepAsync("setup"); } finally { await Promise.all([browser.close(), homarrContainer.stop()]); } diff --git a/e2e/shared/actions/onboarding-actions.ts b/e2e/shared/actions/onboarding-actions.ts index 56c09aba7..0edeb48e8 100644 --- a/e2e/shared/actions/onboarding-actions.ts +++ b/e2e/shared/actions/onboarding-actions.ts @@ -27,14 +27,14 @@ export class OnboardingActions { } } - public async startOnboardingAsync(type: "scratch" | "before 1.0") { - await this.page.locator("button", { hasText: type }).click(); + public async startOnboardingAsync() { + await this.page.getByRole("button", { name: "Get started" }).click(); } public async processUserStepAsync(input: { username: string; password: string; confirmPassword: string }) { - await this.page.waitForSelector("text=administrator user"); + await this.page.getByRole("heading", { name: "Create your administrator" }).waitFor(); - await this.page.getByLabel("Username").fill(input.username); + await this.page.getByLabel("Administrator username").fill(input.username); await this.page.getByLabel("Password", { exact: true }).fill(input.password); await this.page.getByLabel("Confirm password").fill(input.confirmPassword); @@ -42,18 +42,16 @@ export class OnboardingActions { } public async processExternalGroupStepAsync(input: { name: string }) { - await this.page.waitForSelector("text=external provider"); - await this.page.locator("input").fill(input.name); + await this.page.getByRole("heading", { name: "Connect your administrator group" }).waitFor(); + await this.page.getByLabel("External administrator group").fill(input.name); await this.page.locator("css=button[type='submit']").click(); } public async processSettingsStepAsync() { - await this.page.waitForSelector("text=Analytics"); - await this.page.locator("css=button[type='submit']").click(); + await this.page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor(); } public async processIntegrationsStepAsync() { - await this.page.waitForSelector("text=integrations you'd like to connect"); - await this.page.locator("button", { hasText: "Skip for now" }).click(); + await this.page.getByRole("button", { name: "Build my board" }).click(); } } diff --git a/e2e/shared/assertions/onboarding-assertions.ts b/e2e/shared/assertions/onboarding-assertions.ts index c6cffc29c..fabfbd22d 100644 --- a/e2e/shared/assertions/onboarding-assertions.ts +++ b/e2e/shared/assertions/onboarding-assertions.ts @@ -57,6 +57,6 @@ export class OnboardingAssertions { } public async assertFinishStepVisibleAsync() { - await this.page.waitForSelector("text=completed the setup", { timeout: 30_000 }); + await this.page.getByRole("heading", { name: "Your first board is ready." }).waitFor({ timeout: 30_000 }); } } diff --git a/e2e/shared/seed-admin-user.ts b/e2e/shared/seed-admin-user.ts index a34699ec1..a12aa4bb3 100644 --- a/e2e/shared/seed-admin-user.ts +++ b/e2e/shared/seed-admin-user.ts @@ -17,13 +17,13 @@ export const seedAdminUserAsync = async (db: SqliteDatabase, input: SeedAdminUse if (existingOnboarding) { await db.update(sqliteSchema.onboarding).set({ step: "finish", - previousStep: "settings", + previousStep: "setup", }); } else { await db.insert(sqliteSchema.onboarding).values({ id: createId(), step: "finish", - previousStep: "settings", + previousStep: "setup", }); } diff --git a/packages/api/package.json b/packages/api/package.json index 15beaccc6..ea47a10a7 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -17,7 +17,9 @@ "./websocket": "./src/websocket.ts", "./shared": "./src/shared.ts", "./open-api": "./src/open-api.ts", + "./onboarding-claim": "./src/onboarding-claim.ts", "./assistant": "./src/assistant.ts", + "./onboarding-step": "./src/onboarding-step.ts", "./custom-widget/display-data": "./src/router/custom-widget/display-data.ts", "./mcp": "./src/mcp.ts", "./custom-widget-configuration": "./src/custom-widget-configuration.ts" @@ -41,8 +43,6 @@ "@homarr/docker": "workspace:^0.1.0", "@homarr/icons": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0", - "@homarr/old-import": "workspace:^0.1.0", - "@homarr/old-schema": "workspace:^0.1.0", "@homarr/ping": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0", "@homarr/request-handler": "workspace:^0.1.0", diff --git a/packages/api/src/onboarding-claim.ts b/packages/api/src/onboarding-claim.ts new file mode 100644 index 000000000..7298227d4 --- /dev/null +++ b/packages/api/src/onboarding-claim.ts @@ -0,0 +1,112 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +import type { Database } from "@homarr/db"; +import { and, eq } from "@homarr/db"; +import { serverSettings } from "@homarr/db/schema"; + +export const onboardingClaimCookieName = "homarr-onboarding-claim"; +export const onboardingClaimSettingKey = "__onboarding_claim"; +export const onboardingClaimTtlMs = 4 * 60 * 60 * 1000; + +interface StoredOnboardingClaim { + hash: string; + expiresAt: number; +} + +export type ClaimOnboardingResult = + | { status: "issued" | "active"; token: string; expiresAt: number } + | { status: "locked"; expiresAt: number } + | { status: "finished" | "forbidden" }; + +const hashToken = (token: string) => createHash("sha256").update(token).digest("hex"); + +const parseStoredClaim = (value: string): StoredOnboardingClaim | null => { + try { + const parsed = JSON.parse(value) as Partial; + if (typeof parsed.hash !== "string" || !/^[0-9a-f]{64}$/.test(parsed.hash)) return null; + if (typeof parsed.expiresAt !== "number" || !Number.isSafeInteger(parsed.expiresAt)) return null; + return { hash: parsed.hash, expiresAt: parsed.expiresAt }; + } catch { + return null; + } +}; + +const tokenMatches = (claim: StoredOnboardingClaim, token: string, now: number) => { + if (claim.expiresAt <= now || token.length === 0) return false; + const expected = Buffer.from(claim.hash, "hex"); + const actual = Buffer.from(hashToken(token), "hex"); + return expected.length === actual.length && timingSafeEqual(expected, actual); +}; + +const getClaimRowAsync = async (db: Database) => + await db.query.serverSettings.findFirst({ where: eq(serverSettings.settingKey, onboardingClaimSettingKey) }); + +export const getOnboardingClaimTokenFromCookieHeader = (cookieHeader: string | null) => { + if (!cookieHeader) return undefined; + for (const part of cookieHeader.split(";")) { + const separator = part.indexOf("="); + if (separator < 0) continue; + const name = part.slice(0, separator).trim(); + if (name !== onboardingClaimCookieName) continue; + const value = part.slice(separator + 1).trim(); + return value || undefined; + } + return undefined; +}; + +export const isOnboardingClaimValidAsync = async (db: Database, token: string | undefined, now = Date.now()) => { + if (!token) return false; + const row = await getClaimRowAsync(db); + const claim = row ? parseStoredClaim(row.value) : null; + return claim ? tokenMatches(claim, token, now) : false; +}; + +export const isClaimOnlyOnboardingAccessAllowedAsync = async ( + db: Database, + token: string | undefined, + now = Date.now(), +) => { + const existingUser = await db.query.users.findFirst({ columns: { id: true } }); + return !existingUser && (await isOnboardingClaimValidAsync(db, token, now)); +}; + +export const claimOnboardingAsync = async ( + db: Database, + options: { currentToken?: string; force?: boolean; now?: number; ttlMs?: number } = {}, +): Promise => { + const onboarding = await db.query.onboarding.findFirst(); + if (onboarding?.step === "finish") return { status: "finished" }; + const existingUser = await db.query.users.findFirst({ columns: { id: true } }); + if (existingUser && !options.force) return { status: "forbidden" }; + + const now = options.now ?? Date.now(); + const currentRow = await getClaimRowAsync(db); + const currentClaim = currentRow ? parseStoredClaim(currentRow.value) : null; + if (currentClaim && options.currentToken && tokenMatches(currentClaim, options.currentToken, now)) { + return { status: "active", token: options.currentToken, expiresAt: currentClaim.expiresAt }; + } + if (currentClaim && currentClaim.expiresAt > now && !options.force) { + return { status: "locked", expiresAt: currentClaim.expiresAt }; + } + + if (currentRow) { + await db + .delete(serverSettings) + .where(and(eq(serverSettings.settingKey, onboardingClaimSettingKey), eq(serverSettings.value, currentRow.value))); + } + + const token = randomBytes(32).toString("base64url"); + const expiresAt = now + (options.ttlMs ?? onboardingClaimTtlMs); + const value = JSON.stringify({ hash: hashToken(token), expiresAt } satisfies StoredOnboardingClaim); + try { + await db.insert(serverSettings).values({ settingKey: onboardingClaimSettingKey, value }); + } catch (error) { + const winner = await getClaimRowAsync(db); + const winnerClaim = winner ? parseStoredClaim(winner.value) : null; + if (winnerClaim?.expiresAt && winnerClaim.expiresAt > now) { + return { status: "locked", expiresAt: winnerClaim.expiresAt }; + } + throw error; + } + return { status: "issued", token, expiresAt }; +}; diff --git a/packages/api/src/onboarding-step.ts b/packages/api/src/onboarding-step.ts new file mode 100644 index 000000000..a9ed4df75 --- /dev/null +++ b/packages/api/src/onboarding-step.ts @@ -0,0 +1 @@ +export { normalizeOnboardingStep } from "./router/onboard/onboard-queries"; diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 5115e2b8e..bdac1bd65 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -16,7 +16,6 @@ export const appRouter = createTRPCRouter({ location: lazy(() => import("./router/location").then((mod) => mod.locationRouter)), log: lazy(() => import("./router/log").then((mod) => mod.logRouter)), icon: lazy(() => import("./router/icons").then((mod) => mod.iconsRouter)), - import: lazy(() => import("./router/import/import-router").then((mod) => mod.importRouter)), onboard: lazy(() => import("./router/onboard/onboard-router").then((mod) => mod.onboardRouter)), home: lazy(() => import("./router/home").then((mod) => mod.homeRouter)), docker: lazy(() => import("./router/docker/docker-router").then((mod) => mod.dockerRouter)), diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts index 3c623b55a..c25e1edad 100644 --- a/packages/api/src/router/app.ts +++ b/packages/api/src/router/app.ts @@ -18,8 +18,7 @@ import { AppAccessControl } from "./app/app-access-control"; const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg"; export const appRouter = createTRPCRouter({ - getPaginated: permissionRequiredProcedure - .requiresPermission("board-modify-all") + getPaginated: protectedProcedure .input(paginatedSchema) .output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() })) .meta({ diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 54c05ece2..4afa4c764 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -31,26 +31,22 @@ import { sections, users, } from "@homarr/db/schema"; -import type { BoardLane, WidgetKind } from "@homarr/definitions"; +import type { BoardLane, IntegrationKind, WidgetKind } from "@homarr/definitions"; import { boardLanes, emptySuperJSON, everyoneGroup, getBoardLaneColumnCount, getRootSectionLane, + getWidgetIntegrationIssue, + getWidgetIntegrationIssueMessage, getPermissionsWithChildren, getPermissionsWithParents, normalizeBoardLayoutRoles, rootSectionOffsets, widgetDefaultSizes, - widgetIntegrationLimits, - widgetIntegrationSupport, - widgetKindsWithOptionalIntegrations, widgetKinds, } from "@homarr/definitions"; -import { importOldmarrAsync } from "@homarr/old-import"; -import { importJsonFileSchema } from "@homarr/old-import/shared"; -import { oldmarrConfigSchema } from "@homarr/old-schema"; import { addItemToBoardSchema, boardByNameSchema, @@ -193,31 +189,8 @@ export const boardRouter = createTRPCRouter({ }) .query(async ({ ctx }) => { const userId = ctx.session?.user.id; - const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({ - where: eq(boardUserPermissions.userId, userId ?? ""), - }); - - const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({ - where: eq(groupMembers.userId, userId ?? ""), - with: { - group: { - with: { - boardPermissions: {}, - }, - }, - }, - }); - const boardIds = permissionsOfCurrentUserWhenPresent - .map((permission) => permission.boardId) - .concat( - permissionsOfCurrentUserGroupsWhenPresent - .map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId)) - .flat(), - ); - - const currentUserWhenPresent = await ctx.db.query.users.findFirst({ - where: eq(users.id, userId ?? ""), - }); + const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId); + const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships); const dbBoards = await ctx.db.query.boards.findMany({ columns: { @@ -239,30 +212,121 @@ export const boardRouter = createTRPCRouter({ where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""), }, groupPermissions: { - where: - permissionsOfCurrentUserGroupsWhenPresent.length >= 1 - ? inArray( - boardGroupPermissions.groupId, - permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId), - ) - : undefined, + where: groupPermissionWhere, }, }, - // Allow viewing all boards if the user has the permission - where: ctx.session?.user.permissions.includes("board-view-all") - ? undefined - : or( - eq(boards.isPublic, true), - eq(boards.creatorId, ctx.session?.user.id ?? ""), - boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined, - ), + where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds), }); return dbBoards.map((board) => ({ ...board, - isHome: currentUserWhenPresent?.homeBoardId === board.id, - isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id, + isHome: currentUser?.homeBoardId === board.id, + isMobileHome: currentUser?.mobileHomeBoardId === board.id, })); }), + getManageOverview: publicProcedure.query(async ({ ctx }) => { + const userId = ctx.session?.user.id; + const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId); + const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships); + + const dbBoards = await ctx.db.query.boards.findMany({ + columns: { + id: true, + name: true, + logoImageUrl: true, + isPublic: true, + }, + with: { + creator: { + columns: { + id: true, + name: true, + image: true, + email: true, + }, + }, + userPermissions: { + columns: { permission: true }, + where: eq(boardUserPermissions.userId, userId ?? ""), + }, + groupPermissions: { + columns: { permission: true }, + where: groupPermissionWhere, + }, + layouts: { + columns: { + id: true, + columnCount: true, + leftGutterColumnCount: true, + rightGutterColumnCount: true, + breakpoint: true, + role: true, + }, + }, + sections: { + columns: { + id: true, + kind: true, + xOffset: true, + }, + with: { + layouts: { + columns: { + layoutId: true, + parentSectionId: true, + xOffset: true, + yOffset: true, + width: true, + height: true, + }, + }, + }, + }, + items: { + columns: { + id: true, + }, + with: { + layouts: { + columns: { + layoutId: true, + sectionId: true, + xOffset: true, + yOffset: true, + width: true, + height: true, + }, + }, + }, + }, + }, + where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds), + }); + + return dbBoards.map(({ layouts: boardLayouts, sections: boardSections, items: boardItems, ...board }) => { + const previewLayout = + boardLayouts.find((layout) => layout.role === "base") ?? + boardLayouts.toSorted((first, second) => second.breakpoint - first.breakpoint).at(0); + + return { + ...board, + isHome: currentUser?.homeBoardId === board.id, + isMobileHome: currentUser?.mobileHomeBoardId === board.id, + preview: previewLayout + ? { + layouts: [previewLayout], + sections: boardSections.map((section) => ({ + ...section, + layouts: section.layouts.filter((layout) => layout.layoutId === previewLayout.id), + })), + items: boardItems.map((item) => ({ + ...item, + layouts: item.layouts.filter((layout) => layout.layoutId === previewLayout.id), + })), + } + : null, + }; + }); + }), search: publicProcedure .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) })) .query(async ({ ctx, input }) => { @@ -1846,14 +1910,6 @@ export const boardRouter = createTRPCRouter({ }, }); }), - importOldmarrConfig: permissionRequiredProcedure - .requiresPermission("board-create") - .input(importJsonFileSchema) - .mutation(async ({ input, ctx }) => { - const content = await input.file.text(); - const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content)); - await importOldmarrAsync(ctx.db, oldmarr, input.configuration); - }), addItem: protectedProcedure .meta({ openapi: { method: "POST", path: "/api/boards/items", tags: ["boards"], protect: true }, @@ -1877,25 +1933,7 @@ export const boardRouter = createTRPCRouter({ await validateTimetableOptionsChangeAsync(input.options); } - const supportedIntegrations = widgetIntegrationSupport[input.kind]; - const integrationLimit = widgetIntegrationLimits[input.kind] ?? Infinity; - if (input.integrationIds.length > integrationLimit) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `${input.kind} supports at most ${integrationLimit} integration${integrationLimit === 1 ? "" : "s"}`, - }); - } - if (supportedIntegrations === undefined && input.integrationIds.length > 0) { - throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} does not support integrations` }); - } - if ( - supportedIntegrations !== undefined && - input.integrationIds.length === 0 && - !widgetKindsWithOptionalIntegrations.has(input.kind) - ) { - throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} requires an integration` }); - } - + let selectedIntegrationKinds: IntegrationKind[] = []; if (input.integrationIds.length > 0) { const existing = await ctx.db.query.integrations.findMany({ columns: { id: true, kind: true }, @@ -1907,19 +1945,21 @@ export const boardRouter = createTRPCRouter({ throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` }); } + selectedIntegrationKinds = existing.map((integration) => integration.kind); + await Promise.all( input.integrationIds.map((integrationId) => throwIfIntegrationActionForbiddenAsync(ctx, eq(integrations.id, integrationId), "use"), ), ); + } - const incompatible = existing.filter((integration) => !supportedIntegrations?.includes(integration.kind)); - if (incompatible.length > 0) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `${input.kind} does not support integration kind${incompatible.length === 1 ? "" : "s"}: ${incompatible.map((integration) => integration.kind).join(", ")}`, - }); - } + const integrationIssue = getWidgetIntegrationIssue(input.kind, selectedIntegrationKinds); + if (integrationIssue) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: getWidgetIntegrationIssueMessage(input.kind, integrationIssue), + }); } return await serializeBoardItemPlacementAsync(input.boardId, async () => { @@ -2372,6 +2412,59 @@ const getElementsForLayout = (board: BoardForLayoutProjection, layoutId: string) const protectedLayoutRepairPromises = new Map>(); +const getBoardAccessContextAsync = async (db: Database, userId: string | undefined) => { + const [userPermissions, groupMemberships, currentUser] = await Promise.all([ + db.query.boardUserPermissions.findMany({ + where: eq(boardUserPermissions.userId, userId ?? ""), + }), + db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, userId ?? ""), + with: { + group: { + with: { + boardPermissions: {}, + }, + }, + }, + }), + db.query.users.findFirst({ + where: eq(users.id, userId ?? ""), + columns: { + homeBoardId: true, + mobileHomeBoardId: true, + }, + }), + ]); + const boardIds = userPermissions + .map((permission) => permission.boardId) + .concat( + groupMemberships.flatMap((membership) => + membership.group.boardPermissions.map((permission) => permission.boardId), + ), + ); + + return { boardIds, currentUser, groupMemberships }; +}; + +const getBoardGroupPermissionWhere = ( + groupMemberships: Awaited>["groupMemberships"], +) => + groupMemberships.length > 0 + ? inArray( + boardGroupPermissions.groupId, + groupMemberships.map((membership) => membership.groupId), + ) + : eq(boardGroupPermissions.groupId, ""); + +const getAccessibleBoardsWhere = (canViewAll: boolean | undefined, userId: string | undefined, boardIds: string[]) => + canViewAll + ? undefined + : or( + eq(boards.isPublic, true), + eq(boards.creatorId, userId ?? ""), + boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined, + ); + const getFullBoardWithWhereAsync = async ( db: Database, where: SQL, diff --git a/packages/api/src/router/group.ts b/packages/api/src/router/group.ts index 7c00ff125..249cdf910 100644 --- a/packages/api/src/router/group.ts +++ b/packages/api/src/router/group.ts @@ -6,7 +6,7 @@ import { createId } from "@homarr/common"; import type { Database } from "@homarr/db"; import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db"; import { getMaxGroupPositionAsync } from "@homarr/db/queries"; -import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema"; +import { groupMembers, groupPermissions, groups, onboarding, users } from "@homarr/db/schema"; import { everyoneGroup } from "@homarr/definitions"; import { byIdSchema, paginatedSchema } from "@homarr/validation/common"; import { @@ -19,7 +19,6 @@ import { } from "@homarr/validation/group"; import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc"; -import { nextOnboardingStepAsync } from "./onboard/onboard-queries"; export const groupRouter = createTRPCRouter({ getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => { @@ -185,18 +184,28 @@ export const groupRouter = createTRPCRouter({ const maxPosition = await getMaxGroupPositionAsync(ctx.db); const groupId = createId(); - await ctx.db.insert(groups).values({ + const groupRow = { id: groupId, name: input.name, position: maxPosition + 1, - }); + }; - await ctx.db.insert(groupPermissions).values({ - groupId, - permission: "admin", + await handleTransactionsAsync(ctx.db, { + async handleAsync(db, schema) { + await db.transaction(async (transaction) => { + await transaction.insert(schema.groups).values(groupRow); + await transaction.insert(schema.groupPermissions).values({ groupId, permission: "admin" }); + await transaction.update(schema.onboarding).set({ previousStep: "group", step: "setup" }); + }); + }, + handleSync(db) { + db.transaction((transaction) => { + transaction.insert(groups).values(groupRow).run(); + transaction.insert(groupPermissions).values({ groupId, permission: "admin" }).run(); + transaction.update(onboarding).set({ previousStep: "group", step: "setup" }).run(); + }); + }, }); - - await nextOnboardingStepAsync(ctx.db, undefined); }), createGroup: permissionRequiredProcedure .requiresPermission("admin") diff --git a/packages/api/src/router/home.ts b/packages/api/src/router/home.ts index 4b63f9326..f33cea48f 100644 --- a/packages/api/src/router/home.ts +++ b/packages/api/src/router/home.ts @@ -101,7 +101,7 @@ export const homeRouter = createTRPCRouter({ }); } - if (ctx.session?.user.permissions.includes("board-modify-all")) { + if (ctx.session?.user.permissions.includes("app-create")) { statistics.push({ titleKey: "app", subtitleKey: "resources", diff --git a/packages/api/src/router/import/import-router.ts b/packages/api/src/router/import/import-router.ts deleted file mode 100644 index 96c5fd039..000000000 --- a/packages/api/src/router/import/import-router.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { z } from "zod/v4"; - -import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse"; -import { - ensureValidTokenOrThrow, - importInitialOldmarrAsync, - importInitialOldmarrInputSchema, -} from "@homarr/old-import/import"; - -import { createTRPCRouter, onboardingProcedure } from "../../trpc"; -import { nextOnboardingStepAsync } from "../onboard/onboard-queries"; - -export const importRouter = createTRPCRouter({ - analyseInitialOldmarrImport: onboardingProcedure - .requiresStep("import") - .input(analyseOldmarrImportInputSchema) - .mutation(async ({ input }) => { - return await analyseOldmarrImportForRouterAsync(input); - }), - validateToken: onboardingProcedure - .requiresStep("import") - .input( - z.object({ - checksum: z.string(), - token: z.string(), - }), - ) - .mutation(({ input }) => { - try { - ensureValidTokenOrThrow(input.checksum, input.token); - return true; - } catch { - return false; - } - }), - importInitialOldmarrImport: onboardingProcedure - .requiresStep("import") - .input(importInitialOldmarrInputSchema) - .mutation(async ({ ctx, input }) => { - await importInitialOldmarrAsync(ctx.db, input); - await nextOnboardingStepAsync(ctx.db, undefined); - }), -}); diff --git a/packages/api/src/router/onboard/onboard-queries.ts b/packages/api/src/router/onboard/onboard-queries.ts index 242d861e4..8124b7c7e 100644 --- a/packages/api/src/router/onboard/onboard-queries.ts +++ b/packages/api/src/router/onboard/onboard-queries.ts @@ -1,84 +1,35 @@ import { isProviderEnabled } from "@homarr/auth/server"; -import { objectEntries } from "@homarr/common"; -import type { MaybePromise } from "@homarr/common/types"; import type { Database } from "@homarr/db"; -import { eq } from "@homarr/db"; -import { groups, onboarding } from "@homarr/db/schema"; +import { onboarding } from "@homarr/db/schema"; import type { OnboardingStep } from "@homarr/definitions"; -import { credentialsAdminGroup } from "@homarr/definitions"; -export const nextOnboardingStepAsync = async (db: Database, preferredStep: OnboardingStep | undefined) => { +export const nextOnboardingStepAsync = async (db: Database) => { const { current } = await getOnboardingOrFallbackAsync(db); - const nextStepConfiguration = nextSteps[current]; - if (!nextStepConfiguration) return; + if (current !== "start") return; + const nextStep = getNextOnboardingStep(); + if (!nextStep) return; - for (const conditionalStep of objectEntries(nextStepConfiguration)) { - if (!conditionalStep) continue; - const [nextStep, condition] = conditionalStep; - if (condition === "preferred" && nextStep !== preferredStep) continue; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (typeof condition === "boolean" && !condition) continue; - if (typeof condition === "function" && !(await condition(db))) continue; - - await db.update(onboarding).set({ - previousStep: current, - step: nextStep, - }); - return; - } + await db.update(onboarding).set({ previousStep: current, step: nextStep }); }; export const getOnboardingOrFallbackAsync = async (db: Database) => { const value = await db.query.onboarding.findFirst(); if (!value) return { current: "start" as const, previous: null }; - return { current: value.step, previous: value.previousStep }; + return { + current: normalizeOnboardingStep(value.step), + previous: value.previousStep ? normalizeOnboardingStep(value.previousStep) : null, + }; }; -type NextStepCondition = true | "preferred" | ((db: Database) => MaybePromise); - -/** - * The below object is a definition of which can be the next step of the current one. - * If the value is `true`, it means the step can always be the next one. - * If the value is `preferred`, it means that the step can only be reached if the input `preferredStep` is set to the step. - * If the value is a function, it will be called with the database instance and should return a boolean. - * If the value or result is `false`, the step has to be skipped and the next value or callback should be checked. - */ -const nextSteps: Partial>>> = { - start: { - import: "preferred" as const, - user: () => isProviderEnabled("credentials"), - group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"), - settings: true, - }, - import: { - // eslint-disable-next-line no-restricted-syntax - user: async (db: Database) => { - if (!isProviderEnabled("credentials")) return false; - - const adminGroup = await db.query.groups.findFirst({ - where: eq(groups.name, credentialsAdminGroup), - with: { - members: true, - }, - }); - - return !adminGroup || adminGroup.members.length === 0; - }, - group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"), - settings: true, - }, - user: { - group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"), - settings: true, - }, - group: { - settings: true, - }, - settings: { - integrations: true, - }, - integrations: { - finish: true, - }, +const getNextOnboardingStep = (): OnboardingStep => { + if (isProviderEnabled("credentials")) return "user"; + if (isProviderEnabled("ldap") || isProviderEnabled("oidc")) return "group"; + return "setup"; +}; + +export const normalizeOnboardingStep = (step: string | null | undefined): OnboardingStep => { + if (step === "user" || step === "group" || step === "setup" || step === "finish") return step; + if (step === "settings" || step === "integrations") return "setup"; + return "start"; }; diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts index c195c9815..ad002c23f 100644 --- a/packages/api/src/router/onboard/onboard-router.ts +++ b/packages/api/src/router/onboard/onboard-router.ts @@ -1,12 +1,16 @@ +import { createHash } from "node:crypto"; + import { TRPCError } from "@trpc/server"; +import SuperJSON from "superjson"; import { z } from "zod/v4"; import { createId } from "@homarr/common"; import { encryptSecret } from "@homarr/common/server"; -import { eq, like, or } from "@homarr/db"; -import { placeAllWidgetsAsync } from "@homarr/db/queries"; +import { handleTransactionsAsync, eq, inArray, like, or } from "@homarr/db"; +import { getServerSettingByKeyAsync } from "@homarr/db/queries"; import { apps, + boards, groups, icons, integrationItems, @@ -14,21 +18,38 @@ import { integrationSecrets, itemLayouts, items, + layouts, onboarding, + sectionLayouts, + sections, + serverSettings, } from "@homarr/db/schema"; import { + defaultWidgetConfigs, + emptySuperJSON, everyoneGroup, extractContainerImageName, + getBoardLaneColumnCount, getIconUrl, + getRootSectionLane, + getWidgetIntegrationIssue, + getWidgetKindsForIntegration, integrationKinds, integrationSecretKinds, matchIntegrationKindFromContainer, - onboardingSteps, + rootSectionOffsets, + widgetIntegrationLimits, } from "@homarr/definitions"; -import type { IntegrationKind } from "@homarr/definitions"; +import type { IntegrationKind, OnboardingLayoutPreset, WidgetKind } from "@homarr/definitions"; +import { + onboardingCompleteSetupSchema, + onboardingCreateIntegrationSchema, + onboardingDiscoveredAppSchema, +} from "@homarr/validation/onboarding"; import { zodEnumFromArray } from "@homarr/validation/enums"; -import { createTRPCRouter, onboardingProcedure, publicProcedure } from "../../trpc"; +import { onboardingClaimSettingKey } from "../../onboarding-claim"; +import { createTRPCRouter, onboardingClaimedProcedure, onboardingProcedure, publicProcedure } from "../../trpc"; import { MissingSecretError, testConnectionAsync } from "../integration/integration-test-connection"; import { mapTestConnectionError } from "../integration/map-test-connection-error"; import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries"; @@ -43,47 +64,85 @@ interface SuggestedUrlResult { publishedPort: number | null; } -const buildSuggestedUrl = (ports: PortInfo[] | undefined, host: string): SuggestedUrlResult => { - 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 }; +interface DiscoveredIntegration { + sourceId: string; + containerId: string; + containerName: string; + kind: IntegrationKind; + suggestedUrl: string; + publishedPort: number | null; + iconUrl: string | null; + source: "label" | "docker"; + group?: string; + boardName?: string; + host: string; + widgetKind?: WidgetKind; + description?: string; + pingUrl?: string; +} + +interface DiscoveredApp { + sourceId: string; + containerId: string; + containerName: string; + suggestedUrl: string; + publishedPort: number | null; + iconUrl: string | null; + source: "label" | "docker"; + group?: string; + boardName?: string; + description?: string; + pingUrl?: string; + host: string; + widgetKind?: WidgetKind; +} + +const stableOnboardingId = (prefix: "app" | "integration", sourceId: string) => { + const digest = createHash("sha256").update(sourceId).digest("hex").slice(0, 32); + return `onboarding_${prefix}_${digest}`; }; +const usableDockerHost = (host: string) => { + if (host === "socket" || host.startsWith("/")) return null; + return host.replace(/:\d+$/, ""); +}; + +export const buildSuggestedUrl = (ports: PortInfo[] | undefined, host: string): SuggestedUrlResult => { + const port = ports?.find((candidate) => candidate.PublicPort !== undefined); + if (!port?.PublicPort) return { url: "", publishedPort: null }; + const configuredHost = usableDockerHost(host); + const ip = port.IP && port.IP !== "0.0.0.0" && port.IP !== "::" ? port.IP : configuredHost; + return { url: ip ? `http://${ip}:${port.PublicPort}` : "", publishedPort: port.PublicPort }; +}; + +const getLayoutPresetColumnCount = (preset: OnboardingLayoutPreset) => { + const columns: Record = { + focused: 8, + balanced: 10, + wide: 12, + }; + return columns[preset]; +}; + +const normalizeLabelBoardName = (name: string) => name.replace(/[^A-Za-z0-9-_]+/g, "-").replace(/^-+|-+$/g, ""); + +const defaultWidgetConfigByKind = new Map(defaultWidgetConfigs.map((config) => [config.kind, config])); + export const onboardRouter = createTRPCRouter({ - currentStep: publicProcedure.query(async ({ ctx }) => { - return await getOnboardingOrFallbackAsync(ctx.db); - }), - nextStep: publicProcedure - .input( - z.object({ - // Preferred step is only needed for 'preferred' conditions - preferredStep: zodEnumFromArray(onboardingSteps).optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - await nextOnboardingStepAsync(ctx.db, input.preferredStep); - }), - previousStep: publicProcedure.mutation(async ({ ctx }) => { - const { previous } = await getOnboardingOrFallbackAsync(ctx.db); + currentStep: publicProcedure.query(async ({ ctx }) => await getOnboardingOrFallbackAsync(ctx.db)), - if (previous !== "start") { - return; + nextStep: onboardingClaimedProcedure.mutation(async ({ ctx }) => { + const { current } = await getOnboardingOrFallbackAsync(ctx.db); + if (current !== "start") { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "The welcome step is already complete." }); } - - await ctx.db.update(onboarding).set({ - previousStep: null, - step: "start", - }); + await nextOnboardingStepAsync(ctx.db); }), + createIntegration: onboardingProcedure - .requiresStep("integrations") + .requiresStep("setup") .input( - z.object({ - name: z.string().nonempty().max(127), - url: z.string().nonempty(), + onboardingCreateIntegrationSchema.extend({ kind: zodEnumFromArray(integrationKinds), secrets: z.array( z.object({ @@ -94,7 +153,7 @@ export const onboardRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - const result = await testConnectionAsync({ + const testResult = await testConnectionAsync({ id: "new", name: input.name, url: input.url, @@ -102,223 +161,1034 @@ export const onboardRouter = createTRPCRouter({ secrets: input.secrets, }).catch((error) => { if (!(error instanceof MissingSecretError)) throw error; - - throw new TRPCError({ - code: "BAD_REQUEST", - message: error.message, - }); + throw new TRPCError({ code: "BAD_REQUEST", message: error.message }); }); - if (!result.success) { - return { error: mapTestConnectionError(result.error) }; - } + if (!testResult.success) return { error: mapTestConnectionError(testResult.error) }; - const integrationId = createId(); - const appId = createId(); - - await ctx.db.insert(apps).values({ + const integrationId = input.sourceId ? stableOnboardingId("integration", input.sourceId) : createId(); + const existingIntegration = await ctx.db.query.integrations.findFirst({ + where: eq(integrations.id, integrationId), + }); + const appId = + existingIntegration?.appId ?? (input.sourceId ? stableOnboardingId("app", input.sourceId) : createId()); + const existingApp = await ctx.db.query.apps.findFirst({ where: eq(apps.id, appId) }); + const appRow = { id: appId, name: input.name, - iconUrl: getIconUrl(input.kind), + iconUrl: input.iconUrl ?? getIconUrl(input.kind), href: input.url, - pingUrl: input.url, - description: null, - }); - - await ctx.db.insert(integrations).values({ + pingUrl: input.pingUrl ?? input.url, + description: input.description ?? null, + }; + const integrationRow = { id: integrationId, name: input.name, url: input.url, kind: input.kind, appId, + }; + const secretRows = input.secrets.map((secret) => ({ + kind: secret.kind, + value: encryptSecret(secret.value), + integrationId, + })); + + await handleTransactionsAsync(ctx.db, { + async handleAsync(db, schema) { + await db.transaction(async (transaction) => { + if (existingApp) { + await transaction.update(schema.apps).set(appRow).where(eq(schema.apps.id, appId)); + } else { + await transaction.insert(schema.apps).values(appRow); + } + if (existingIntegration) { + await transaction + .update(schema.integrations) + .set(integrationRow) + .where(eq(schema.integrations.id, integrationId)); + await transaction + .delete(schema.integrationSecrets) + .where(eq(schema.integrationSecrets.integrationId, integrationId)); + } else { + await transaction.insert(schema.integrations).values(integrationRow); + } + if (secretRows.length > 0) await transaction.insert(schema.integrationSecrets).values(secretRows); + }); + }, + handleSync(db) { + db.transaction((transaction) => { + if (existingApp) { + transaction.update(apps).set(appRow).where(eq(apps.id, appId)).run(); + } else { + transaction.insert(apps).values(appRow).run(); + } + if (existingIntegration) { + transaction.update(integrations).set(integrationRow).where(eq(integrations.id, integrationId)).run(); + transaction.delete(integrationSecrets).where(eq(integrationSecrets.integrationId, integrationId)).run(); + } else { + transaction.insert(integrations).values(integrationRow).run(); + } + if (secretRows.length > 0) transaction.insert(integrationSecrets).values(secretRows).run(); + }); + }, }); - if (input.secrets.length >= 1) { - await ctx.db.insert(integrationSecrets).values( - input.secrets.map((secret) => ({ - kind: secret.kind, - value: encryptSecret(secret.value), - integrationId, - })), - ); - } + return { id: integrationId, appId }; }), - 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; - }[], - }; + + discoverDockerServices: onboardingProcedure.requiresStep("setup").query(async ({ ctx }) => { + const empty = { integrations: [] as DiscoveredIntegration[], apps: [] as DiscoveredApp[] }; 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 { listDiscoveredContainersAsync, dockerLabels } = await import("@homarr/docker"); + const discovery = await listDiscoveredContainersAsync(); + const successfulHosts = discovery.hosts.filter((host) => host.status === "success"); + const unavailableHosts = discovery.hosts.filter((host) => host.status === "unavailable"); + const containers = successfulHosts.flatMap((hostResult) => + hostResult.containers + .filter((container) => !(dockerLabels.hide in (container.Labels ?? {}))) + .map((container) => ({ ...container, host: hostResult.host })), + ); + const labeledContainerKeys = new Set( + discovery.services.map((service) => `${service.host}:${service.containerId}`), + ); + const imageCandidates = containers.filter( + (container) => !labeledContainerKeys.has(`${container.host}:${container.Id}`), + ); + const likeQueries = imageCandidates.map((container) => + like(icons.name, `%${extractContainerImageName(container.Image)}%`), ); - - 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; + return ( + dbIcons.find((icon) => icon.name.toLowerCase() === normalized)?.url ?? + dbIcons.find((icon) => icon.name.toLowerCase().startsWith(normalized))?.url ?? + null + ); }; - for (const container of containers) { + const discoveredIntegrations: DiscoveredIntegration[] = []; + const discoveredApps: DiscoveredApp[] = []; + + for (const service of discovery.services) { + if (service.integrationKind) { + discoveredIntegrations.push({ + sourceId: service.sourceId, + containerId: service.containerId, + containerName: service.name, + kind: service.integrationKind, + suggestedUrl: service.href, + publishedPort: null, + iconUrl: service.icon ?? getIconUrl(service.integrationKind), + source: "label", + group: service.group, + boardName: service.boardName, + host: service.host, + widgetKind: service.widgetKind, + description: service.description, + pingUrl: service.pingUrl, + }); + } else { + discoveredApps.push({ + sourceId: service.sourceId, + containerId: service.containerId, + containerName: service.name, + suggestedUrl: service.href, + publishedPort: null, + iconUrl: service.icon ?? null, + source: "label", + group: service.group, + boardName: service.boardName, + description: service.description, + pingUrl: service.pingUrl, + host: service.host, + widgetKind: service.widgetKind, + }); + } + } + + for (const container of imageCandidates) { 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 }); + const sourceId = `docker:${container.host}:${container.Id}`; - const kind = matchIntegrationKindFromContainer({ - image: container.Image, - name: containerName, - }); - - if (kind && !seenKinds.has(kind)) { - seenKinds.add(kind); + if (kind) { discoveredIntegrations.push({ + sourceId, containerId: container.Id, containerName, kind, suggestedUrl, publishedPort, iconUrl, + source: "docker", + host: container.host, }); - } else if (!kind && dbIcon) { + } else if (dbIcon) { discoveredApps.push({ + sourceId, containerId: container.Id, containerName, suggestedUrl, publishedPort, iconUrl, + source: "docker", + host: container.host, }); } } + const hosts = discovery.hosts.map((host) => + host.status === "success" + ? { host: host.host, status: host.status, containerCount: host.containers.length } + : { host: host.host, status: host.status, reason: host.reason, containerCount: 0 }, + ); + const status = + successfulHosts.length === 0 + ? "unavailable" + : unavailableHosts.length > 0 + ? "partial" + : discoveredIntegrations.length === 0 && discoveredApps.length === 0 + ? "empty" + : "success"; + + return { status, hosts, integrations: discoveredIntegrations, apps: discoveredApps }; + } catch (error) { + const reason = error instanceof Error ? error.message : "Docker discovery failed"; return { - status: "success" as const, - integrations: discoveredIntegrations, - apps: discoveredApps, + status: "unavailable" as const, + hosts: [{ host: "unknown", status: "unavailable" as const, reason, containerCount: 0 }], + ...empty, }; - } 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(), - }), - ), - ) + .requiresStep("setup") + .input(z.array(onboardingDiscoveredAppSchema).max(128)) .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 planned = [] as Array<{ + id: string; + sourceId?: string; + existing: boolean; + row: typeof apps.$inferInsert; + }>; + const seenIds = new Set(); - const allIntegrations = await db.query.integrations.findMany(); - const allApps = await db.query.apps.findMany(); + for (const app of input) { + const stableId = app.sourceId ? stableOnboardingId("app", app.sourceId) : undefined; + const existing = stableId + ? await ctx.db.query.apps.findFirst({ where: eq(apps.id, stableId) }) + : app.href + ? await ctx.db.query.apps.findFirst({ where: eq(apps.href, app.href) }) + : undefined; + const id = existing?.id ?? stableId ?? createId(); + if (seenIds.has(id)) continue; + seenIds.add(id); + planned.push({ + id, + sourceId: app.sourceId, + existing: Boolean(existing), + row: { + id, + name: app.name, + iconUrl: app.iconUrl ?? defaultIcon, + href: app.href, + pingUrl: app.pingUrl ?? app.href, + description: app.description ?? null, + }, + }); + } - 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"); - if (section && board.layouts.length > 0) { - const existingItems = await db.query.items.findMany({ - where: eq(items.boardId, board.id), + await handleTransactionsAsync(ctx.db, { + async handleAsync(db, schema) { + await db.transaction(async (transaction) => { + for (const app of planned) { + if (app.existing) { + await transaction.update(schema.apps).set(app.row).where(eq(schema.apps.id, app.id)); + } else { + await transaction.insert(schema.apps).values(app.row); + } + } + }); + }, + handleSync(db) { + db.transaction((transaction) => { + for (const app of planned) { + if (app.existing) { + transaction.update(apps).set(app.row).where(eq(apps.id, app.id)).run(); + } else { + transaction.insert(apps).values(app.row).run(); + } + } + }); + }, }); - 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)); + + return { apps: planned.map(({ id, sourceId }) => ({ id, sourceId })) }; + }), + + completeSetup: onboardingProcedure + .requiresStep("setup") + .input(onboardingCompleteSetupSchema) + .mutation(async ({ ctx, input }) => { + const boardList = await ctx.db.query.boards.findMany({ + with: { + sections: { with: { layouts: true } }, + layouts: true, + items: { with: { integrations: true, layouts: true } }, + }, + }); + const existingTargetBoard = input.board.id + ? boardList.find((board) => board.id === input.board.id) + : boardList.length === 1 + ? boardList[0] + : undefined; + const pendingBoardRow = + boardList.length === 0 && input.board.id === undefined + ? { + id: createId(), + name: input.board.name, + creatorId: ctx.session?.user.id ?? null, + } + : undefined; + const pendingLayouts = pendingBoardRow + ? [ + { + id: createId(), + name: "Mobile", + boardId: pendingBoardRow.id, + columnCount: 3, + leftGutterColumnCount: 0, + rightGutterColumnCount: 0, + breakpoint: 0, + role: "mobile" as const, + }, + { + id: createId(), + name: "Base", + boardId: pendingBoardRow.id, + columnCount: 10, + leftGutterColumnCount: 0, + rightGutterColumnCount: 0, + breakpoint: 768, + role: "base" as const, + }, + ] + : []; + const targetBoard = + existingTargetBoard ?? + (pendingBoardRow + ? ({ + ...pendingBoardRow, + isPublic: false, + pageTitle: null, + metaTitle: null, + logoImageUrl: null, + faviconImageUrl: null, + backgroundImageUrl: null, + backgroundImageAttachment: "fixed", + backgroundImageRepeat: "no-repeat", + backgroundImageSize: "cover", + primaryColor: input.board.primaryColor, + secondaryColor: input.board.secondaryColor, + opacity: 100, + customCss: null, + iconColor: null, + itemRadius: input.board.itemRadius, + disableStatus: false, + sections: [], + layouts: pendingLayouts, + items: [], + } satisfies (typeof boardList)[number]) + : undefined); + if (!targetBoard) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Select the exact board to configure when more than one board exists.", + }); } - if (existingItems.length > 0) { - await db.delete(items).where(eq(items.boardId, board.id)); + const selectedIntegrations = + input.selectedIntegrationIds.length > 0 + ? await ctx.db.query.integrations.findMany({ where: inArray(integrations.id, input.selectedIntegrationIds) }) + : []; + const selectedApps = + input.selectedAppIds.length > 0 + ? await ctx.db.query.apps.findMany({ where: inArray(apps.id, input.selectedAppIds) }) + : []; + if (selectedIntegrations.length !== input.selectedIntegrationIds.length) { + throw new TRPCError({ code: "BAD_REQUEST", message: "One or more selected integrations no longer exist." }); + } + if (selectedApps.length !== input.selectedAppIds.length) { + throw new TRPCError({ code: "BAD_REQUEST", message: "One or more selected apps no longer exist." }); } - await placeAllWidgetsAsync( - db, - board.layouts.map((layout) => ({ - boardId: board.id, - sectionId: section.id, - layoutId: layout.id, - columnCount: layout.columnCount, - })), - allIntegrations, - allApps, + const selectedDockerSources = + input.selectedDockerSourceIds.length > 0 + ? await import("@homarr/docker") + .then(({ listDiscoveredContainersAsync }) => listDiscoveredContainersAsync()) + .then((discovery) => { + const selectedIds = new Set(input.selectedDockerSourceIds); + return discovery.services.filter((service) => selectedIds.has(service.sourceId)); + }) + : []; + const discoveredSourceIds = new Set(selectedDockerSources.map((service) => service.sourceId)); + const missingDockerSourceIds = input.selectedDockerSourceIds.filter( + (sourceId) => !discoveredSourceIds.has(sourceId), ); - } + const selectedLabelBoardNames = [ + ...new Set( + selectedDockerSources.flatMap((service) => (service.boardName === undefined ? [] : [service.boardName])), + ), + ]; + const isFreshSoleBoard = + pendingBoardRow !== undefined || + (boardList.length === 1 && targetBoard.name === "dashboard" && targetBoard.creatorId === null); + const requestedBoardNames = new Set([targetBoard.name, input.board.name]); + const matchingSelectedBoardName = selectedLabelBoardNames.find((name) => + requestedBoardNames.has(normalizeLabelBoardName(name)), + ); + const selectedLabelBoardName = matchingSelectedBoardName ?? selectedLabelBoardNames[0]; + let targetBoardName = input.board.name; + if (isFreshSoleBoard && selectedLabelBoardNames.length === 1 && selectedLabelBoardName) { + const normalizedLabelBoardName = normalizeLabelBoardName(selectedLabelBoardName); + const parsedName = onboardingCompleteSetupSchema.shape.board.shape.name.safeParse(normalizedLabelBoardName); + if (!parsedName.success) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The selected homarr.board label is not a valid board name.", + }); + } + targetBoardName = parsedName.data; + } + if (boardList.some((board) => board.id !== targetBoard.id && board.name === targetBoardName)) { + throw new TRPCError({ code: "CONFLICT", message: "A board with this name already exists." }); + } + const targetBoardNames = new Set([targetBoard.name, targetBoardName]); + const ignoredDockerSources = selectedDockerSources.filter( + (service) => + service.boardName !== undefined && !targetBoardNames.has(normalizeLabelBoardName(service.boardName)), + ); + const ignoredDockerSourceIds = new Set(ignoredDockerSources.map((service) => service.sourceId)); + const acceptedDockerSources = selectedDockerSources.filter( + (service) => !ignoredDockerSourceIds.has(service.sourceId), + ); + const groupedDockerSources = acceptedDockerSources.filter((service) => service.group !== undefined); + const ungroupedDockerSources = acceptedDockerSources.filter((service) => service.group === undefined); - await nextOnboardingStepAsync(db, undefined); - }), + const emptySection = targetBoard.sections.find( + (section) => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main", + ); + const sectionId = emptySection?.id ?? createId(); + const baseLayout = targetBoard.layouts.find((layout) => layout.role === "base") ?? targetBoard.layouts.at(-1); + if (!baseLayout || targetBoard.layouts.length === 0) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "The target board has no layouts." }); + } + + const requestedGutters = { + left: input.board.leftSidebar ? 1 : 0, + right: input.board.rightSidebar ? 1 : 0, + }; + const mainRootSectionIds = new Set( + targetBoard.sections + .filter((section) => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main") + .map((section) => section.id), + ); + const requiredColumns = Math.max( + 1, + ...targetBoard.items.flatMap((item) => + item.layouts + .filter((layout) => layout.layoutId === baseLayout.id && mainRootSectionIds.has(layout.sectionId)) + .map((layout) => layout.xOffset + layout.width), + ), + ...targetBoard.sections.flatMap((section) => + section.kind === "container" + ? section.layouts + .filter( + (layout) => + layout.layoutId === baseLayout.id && + layout.parentSectionId !== null && + mainRootSectionIds.has(layout.parentSectionId), + ) + .map((layout) => layout.xOffset + layout.width) + : [], + ), + ); + let leftGutterColumnCount = requestedGutters.left; + let rightGutterColumnCount = requestedGutters.right; + const desiredColumns = getLayoutPresetColumnCount(input.board.layoutPreset); + if (requiredColumns + leftGutterColumnCount + rightGutterColumnCount > 24) rightGutterColumnCount = 0; + if (requiredColumns + leftGutterColumnCount + rightGutterColumnCount > 24) leftGutterColumnCount = 0; + const baseColumnCount = Math.min( + 24, + Math.max(desiredColumns, requiredColumns + leftGutterColumnCount + rightGutterColumnCount), + ); + + const rootSectionsToInsert: (typeof sections.$inferInsert)[] = []; + const ensureRootSection = (lane: "left" | "right", enabled: boolean) => { + if (!enabled) return; + const exists = targetBoard.sections.some( + (section) => section.kind === "empty" && getRootSectionLane(section.xOffset) === lane, + ); + if (!exists) { + rootSectionsToInsert.push({ + id: createId(), + boardId: targetBoard.id, + kind: "empty", + xOffset: rootSectionOffsets[lane], + yOffset: 0, + }); + } + }; + ensureRootSection("left", input.board.leftSidebar); + ensureRootSection("right", input.board.rightSidebar); + + const existingItemsByKind = new Map(); + const existingAppIds = new Set(); + for (const item of targetBoard.items) { + if (!existingItemsByKind.has(item.kind)) existingItemsByKind.set(item.kind, item); + if (item.kind !== "app") continue; + try { + const options = SuperJSON.parse<{ appId?: unknown }>(item.options); + if (typeof options.appId === "string") existingAppIds.add(options.appId); + } catch { + // A malformed existing item must not make first-run setup destructive. + } + } + + const newItems: (typeof items.$inferInsert)[] = []; + const newItemLayouts: (typeof itemLayouts.$inferInsert)[] = []; + const newIntegrationItems: (typeof integrationItems.$inferInsert)[] = []; + const skippedWidgets: Array<{ + sourceId: string; + widgetKind: WidgetKind; + code: "integration-not-supported" | "integration-required" | "incompatible-integration"; + integrationKind?: IntegrationKind; + }> = []; + const newContainerSections: (typeof sections.$inferInsert)[] = []; + const newSectionLayouts: (typeof sectionLayouts.$inferInsert)[] = []; + const contexts = targetBoard.layouts.map((layout) => { + const configuredLayout = + layout.id === baseLayout.id + ? { ...layout, columnCount: baseColumnCount, leftGutterColumnCount, rightGutterColumnCount } + : layout; + const columnCount = getBoardLaneColumnCount(configuredLayout, "main"); + const existing = targetBoard.items.flatMap((item) => + item.layouts.filter((position) => position.layoutId === layout.id && position.sectionId === sectionId), + ); + const existingContainers = targetBoard.sections.flatMap((section) => + section.kind === "container" + ? section.layouts.filter( + (position) => position.layoutId === layout.id && position.parentSectionId === sectionId, + ) + : [], + ); + return { + layoutId: layout.id, + columnCount, + xOffset: 0, + yOffset: [...existing, ...existingContainers].reduce( + (max, position) => Math.max(max, position.yOffset + position.height), + 0, + ), + rowMaxHeight: 0, + }; + }); + + const placeNewItem = ( + kind: WidgetKind, + options: string, + width: number, + height: number, + sectionIdsByLayout?: ReadonlyMap, + positionsByLayout?: ReadonlyMap, + ) => { + const id = createId(); + newItems.push({ id, boardId: targetBoard.id, kind, options, advancedOptions: emptySuperJSON }); + for (const context of contexts) { + const sectionForLayout = sectionIdsByLayout?.get(context.layoutId); + const position = positionsByLayout?.get(context.layoutId); + if (sectionForLayout && position) { + newItemLayouts.push({ + itemId: id, + sectionId: sectionForLayout, + layoutId: context.layoutId, + xOffset: position.xOffset, + yOffset: position.yOffset, + width: position.width, + height, + }); + continue; + } + const itemWidth = Math.min(context.columnCount, width); + if (context.xOffset + itemWidth > context.columnCount) { + context.xOffset = 0; + context.yOffset += context.rowMaxHeight; + context.rowMaxHeight = 0; + } + newItemLayouts.push({ + itemId: id, + sectionId, + layoutId: context.layoutId, + xOffset: context.xOffset, + yOffset: context.yOffset, + width: itemWidth, + height, + }); + context.xOffset += itemWidth; + context.rowMaxHeight = Math.max(context.rowMaxHeight, height); + } + return id; + }; + + const selectedIntegrationIdsByWidget = new Map(); + const labeledIntegrationSourceById = new Map( + selectedDockerSources + .filter((service) => service.integrationKind !== undefined) + .map((service) => [stableOnboardingId("integration", service.sourceId), service]), + ); + for (const integration of selectedIntegrations) { + const labeledSource = labeledIntegrationSourceById.get(integration.id); + if (labeledSource && ignoredDockerSourceIds.has(labeledSource.sourceId)) continue; + for (const kind of getWidgetKindsForIntegration(integration.kind)) { + if (labeledSource?.widgetKind === kind && input.selectedWidgetKinds.includes(kind)) { + continue; + } + const ids = selectedIntegrationIdsByWidget.get(kind) ?? []; + ids.push(integration.id); + selectedIntegrationIdsByWidget.set(kind, ids); + } + } + for (const [kind, integrationIdsForWidget] of selectedIntegrationIdsByWidget) { + const config = defaultWidgetConfigByKind.get(kind); + if (config?.skip) continue; + const existing = existingItemsByKind.get(kind); + const limit = widgetIntegrationLimits[kind] ?? Number.POSITIVE_INFINITY; + let unlinkedIntegrationIds = integrationIdsForWidget; + if (existing) { + const linkedIds = new Set(existing.integrations.map((row) => row.integrationId)); + const remainingCapacity = Math.max(0, limit - linkedIds.size); + const idsForExisting = integrationIdsForWidget + .filter((integrationId) => !linkedIds.has(integrationId)) + .slice(0, remainingCapacity); + newIntegrationItems.push(...idsForExisting.map((integrationId) => ({ itemId: existing.id, integrationId }))); + const handledIds = new Set([...linkedIds, ...idsForExisting]); + unlinkedIntegrationIds = integrationIdsForWidget.filter((integrationId) => !handledIds.has(integrationId)); + } + const batchSize = Number.isFinite(limit) ? Math.max(1, limit) : Math.max(1, unlinkedIntegrationIds.length); + for (let index = 0; index < unlinkedIntegrationIds.length; index += batchSize) { + const integrationIdsForItem = unlinkedIntegrationIds.slice(index, index + batchSize); + const integrationKindsForItem = integrationIdsForItem.flatMap((integrationId) => { + const integration = selectedIntegrations.find((candidate) => candidate.id === integrationId); + return integration ? [integration.kind] : []; + }); + if (getWidgetIntegrationIssue(kind, integrationKindsForItem)) continue; + const itemId = placeNewItem( + kind, + config?.options ? SuperJSON.stringify(config.options) : emptySuperJSON, + config?.width ?? 2, + config?.height ?? 2, + ); + newIntegrationItems.push(...integrationIdsForItem.map((integrationId) => ({ itemId, integrationId }))); + } + } + const selectedLabelWidgetKinds = new Set( + acceptedDockerSources.flatMap((service) => (service.widgetKind ? [service.widgetKind] : [])), + ); + for (const kind of input.selectedWidgetKinds.filter((kind) => !selectedLabelWidgetKinds.has(kind))) { + if (existingItemsByKind.has(kind) || newItems.some((item) => item.kind === kind)) continue; + if (getWidgetIntegrationIssue(kind, [])) continue; + const config = defaultWidgetConfigByKind.get(kind); + if (config?.skip) continue; + placeNewItem( + kind, + config?.options ? SuperJSON.stringify(config.options) : emptySuperJSON, + config?.width ?? 2, + config?.height ?? 2, + ); + } + const dockerSourceByAppId = new Map( + selectedDockerSources.map((service) => [stableOnboardingId("app", service.sourceId), service]), + ); + for (const app of selectedApps.filter((app) => !dockerSourceByAppId.has(app.id))) { + if (existingAppIds.has(app.id)) continue; + placeNewItem("app", SuperJSON.stringify({ appId: app.id, openInNewTab: true, showTitle: true }), 1, 1); + } + + for (const service of ungroupedDockerSources) { + const app = selectedApps.find((candidate) => candidate.id === stableOnboardingId("app", service.sourceId)); + if (app && !existingAppIds.has(app.id)) { + placeNewItem("app", SuperJSON.stringify({ appId: app.id, openInNewTab: true, showTitle: true }), 1, 1); + } + if (!service.widgetKind || !input.selectedWidgetKinds.includes(service.widgetKind)) continue; + const integration = service.integrationKind + ? selectedIntegrations.find( + (candidate) => candidate.id === stableOnboardingId("integration", service.sourceId), + ) + : undefined; + const issue = getWidgetIntegrationIssue(service.widgetKind, integration ? [integration.kind] : []); + if (issue) { + if (issue.code !== "integration-limit") { + skippedWidgets.push({ + sourceId: service.sourceId, + widgetKind: service.widgetKind, + code: issue.code, + integrationKind: integration?.kind, + }); + } + continue; + } + const existing = existingItemsByKind.get(service.widgetKind); + if (existing && integration) { + const linkedIds = new Set([ + ...existing.integrations.map((row) => row.integrationId), + ...newIntegrationItems.filter((row) => row.itemId === existing.id).map((row) => row.integrationId), + ]); + const limit = widgetIntegrationLimits[service.widgetKind] ?? Number.POSITIVE_INFINITY; + if (!linkedIds.has(integration.id) && linkedIds.size < limit) { + newIntegrationItems.push({ itemId: existing.id, integrationId: integration.id }); + continue; + } + if (linkedIds.has(integration.id)) continue; + } else if (existing) { + continue; + } + const config = defaultWidgetConfigByKind.get(service.widgetKind); + if (config?.skip) continue; + const itemId = placeNewItem( + service.widgetKind, + config?.options ? SuperJSON.stringify(config.options) : emptySuperJSON, + config?.width ?? 2, + config?.height ?? 2, + ); + if (integration) newIntegrationItems.push({ itemId, integrationId: integration.id }); + } + + const groupedSourcesByName = Map.groupBy( + groupedDockerSources.filter((service) => { + const hasSelectedApp = selectedApps.some((app) => app.id === stableOnboardingId("app", service.sourceId)); + const hasSelectedWidget = + service.widgetKind !== undefined && + input.selectedWidgetKinds.includes(service.widgetKind) && + (service.integrationKind === undefined || + selectedIntegrations.some( + (integration) => integration.id === stableOnboardingId("integration", service.sourceId), + )); + return hasSelectedApp || hasSelectedWidget; + }), + (service) => service.group, + ); + for (const [groupName, services] of groupedSourcesByName) { + const groupedItemPlans: Array<{ + kind: WidgetKind; + options: string; + width: number; + height: number; + integrationId?: string; + }> = []; + for (const service of services) { + const app = selectedApps.find((candidate) => candidate.id === stableOnboardingId("app", service.sourceId)); + if (app && !existingAppIds.has(app.id)) { + groupedItemPlans.push({ + kind: "app", + options: SuperJSON.stringify({ appId: app.id, openInNewTab: true, showTitle: true }), + width: 1, + height: 1, + }); + } + + if (!service.widgetKind || !input.selectedWidgetKinds.includes(service.widgetKind)) continue; + const integration = service.integrationKind + ? selectedIntegrations.find( + (candidate) => candidate.id === stableOnboardingId("integration", service.sourceId), + ) + : undefined; + const issue = getWidgetIntegrationIssue(service.widgetKind, integration ? [integration.kind] : []); + if (issue) { + if (issue.code !== "integration-limit") { + skippedWidgets.push({ + sourceId: service.sourceId, + widgetKind: service.widgetKind, + code: issue.code, + integrationKind: integration?.kind, + }); + } + continue; + } + + const existing = existingItemsByKind.get(service.widgetKind); + if (existing && integration) { + const linkedIds = new Set([ + ...existing.integrations.map((row) => row.integrationId), + ...newIntegrationItems.filter((row) => row.itemId === existing.id).map((row) => row.integrationId), + ]); + const limit = widgetIntegrationLimits[service.widgetKind] ?? Number.POSITIVE_INFINITY; + if (!linkedIds.has(integration.id) && linkedIds.size < limit) { + newIntegrationItems.push({ itemId: existing.id, integrationId: integration.id }); + continue; + } + if (linkedIds.has(integration.id)) continue; + } else if (existing) { + continue; + } + + const config = defaultWidgetConfigByKind.get(service.widgetKind); + if (config?.skip) continue; + groupedItemPlans.push({ + kind: service.widgetKind, + options: config?.options ? SuperJSON.stringify(config.options) : emptySuperJSON, + width: config?.width ?? 2, + height: config?.height ?? 2, + integrationId: integration?.id, + }); + } + if (groupedItemPlans.length === 0) continue; + + const containerSectionId = createId(); + newContainerSections.push({ + id: containerSectionId, + boardId: targetBoard.id, + kind: "container", + options: SuperJSON.stringify({ + title: groupName, + customCssClasses: [], + borderColor: "", + showLabel: true, + collapsible: false, + showOpenAll: false, + }), + }); + const sectionIdsByLayout = new Map(targetBoard.layouts.map((layout) => [layout.id, containerSectionId])); + const packingByLayout = new Map< + string, + { columns: number; xOffset: number; yOffset: number; rowMaxHeight: number; maxBottom: number } + >(); + for (const context of contexts) { + if (context.xOffset > 0 || context.rowMaxHeight > 0) context.yOffset += context.rowMaxHeight; + context.xOffset = 0; + context.rowMaxHeight = 0; + packingByLayout.set(context.layoutId, { + columns: context.columnCount, + xOffset: 0, + yOffset: 0, + rowMaxHeight: 0, + maxBottom: 0, + }); + } + + const placeGroupedItem = (kind: WidgetKind, options: string, width: number, height: number) => { + const positions = new Map(); + for (const context of contexts) { + const packing = packingByLayout.get(context.layoutId); + if (!packing) continue; + const itemWidth = Math.min(packing.columns, width); + if (packing.xOffset + itemWidth > packing.columns) { + packing.xOffset = 0; + packing.yOffset += packing.rowMaxHeight; + packing.rowMaxHeight = 0; + } + positions.set(context.layoutId, { + xOffset: packing.xOffset, + yOffset: packing.yOffset, + width: itemWidth, + }); + packing.xOffset += itemWidth; + packing.rowMaxHeight = Math.max(packing.rowMaxHeight, height); + packing.maxBottom = Math.max(packing.maxBottom, packing.yOffset + height); + } + return placeNewItem(kind, options, width, height, sectionIdsByLayout, positions); + }; + + for (const plan of groupedItemPlans) { + const groupedItemId = placeGroupedItem(plan.kind, plan.options, plan.width, plan.height); + if (plan.integrationId) { + newIntegrationItems.push({ itemId: groupedItemId, integrationId: plan.integrationId }); + } + } + for (const context of contexts) { + const packing = packingByLayout.get(context.layoutId); + if (!packing) continue; + const height = Math.max(1, packing.maxBottom); + newSectionLayouts.push({ + sectionId: containerSectionId, + parentSectionId: sectionId, + layoutId: context.layoutId, + xOffset: 0, + yOffset: context.yOffset, + width: context.columnCount, + height, + }); + context.yOffset += height; + } + } + + const currentAppearance = await getServerSettingByKeyAsync(ctx.db, "appearance"); + const currentCulture = await getServerSettingByKeyAsync(ctx.db, "culture"); + const currentAnalytics = await getServerSettingByKeyAsync(ctx.db, "analytics"); + const currentBoardSettings = await getServerSettingByKeyAsync(ctx.db, "board"); + const settingValues = { + appearance: SuperJSON.stringify({ ...currentAppearance, defaultColorScheme: input.server.defaultColorScheme }), + culture: SuperJSON.stringify({ ...currentCulture, defaultLocale: input.server.defaultLocale }), + analytics: SuperJSON.stringify({ + ...currentAnalytics, + enableGeneral: input.server.analyticsEnabled ?? currentAnalytics.enableGeneral, + }), + board: SuperJSON.stringify({ + ...currentBoardSettings, + homeBoardId: targetBoard.id, + mobileHomeBoardId: targetBoard.id, + }), + }; + + await handleTransactionsAsync(ctx.db, { + async handleAsync(db, schema) { + await db.transaction(async (transaction) => { + const boardValues = { + name: targetBoardName, + primaryColor: input.board.primaryColor, + secondaryColor: input.board.secondaryColor, + itemRadius: input.board.itemRadius, + }; + if (pendingBoardRow) { + await transaction.insert(schema.boards).values({ ...pendingBoardRow, ...boardValues }); + await transaction + .insert(schema.layouts) + .values( + pendingLayouts.map((layout) => + layout.id === baseLayout.id + ? { ...layout, columnCount: baseColumnCount, leftGutterColumnCount, rightGutterColumnCount } + : layout, + ), + ); + } else { + await transaction.update(schema.boards).set(boardValues).where(eq(schema.boards.id, targetBoard.id)); + await transaction + .update(schema.layouts) + .set({ columnCount: baseColumnCount, leftGutterColumnCount, rightGutterColumnCount }) + .where(eq(schema.layouts.id, baseLayout.id)); + } + if (!emptySection) { + await transaction.insert(schema.sections).values({ + id: sectionId, + boardId: targetBoard.id, + kind: "empty", + xOffset: 0, + yOffset: 0, + }); + } + if (rootSectionsToInsert.length > 0) { + await transaction.insert(schema.sections).values(rootSectionsToInsert); + } + if (newContainerSections.length > 0) { + await transaction.insert(schema.sections).values(newContainerSections); + } + if (newSectionLayouts.length > 0) { + await transaction.insert(schema.sectionLayouts).values(newSectionLayouts); + } + if (newItems.length > 0) await transaction.insert(schema.items).values(newItems); + if (newItemLayouts.length > 0) await transaction.insert(schema.itemLayouts).values(newItemLayouts); + if (newIntegrationItems.length > 0) { + await transaction.insert(schema.integrationItems).values(newIntegrationItems); + } + for (const [settingKey, value] of Object.entries(settingValues)) { + await transaction + .update(schema.serverSettings) + .set({ value }) + .where(eq(schema.serverSettings.settingKey, settingKey as keyof typeof settingValues)); + } + await transaction + .update(schema.groups) + .set({ homeBoardId: targetBoard.id, mobileHomeBoardId: targetBoard.id }) + .where(eq(schema.groups.name, everyoneGroup)); + await transaction + .delete(schema.serverSettings) + .where(eq(schema.serverSettings.settingKey, onboardingClaimSettingKey)); + await transaction.update(schema.onboarding).set({ previousStep: "setup", step: "finish" }); + }); + }, + handleSync(db) { + db.transaction((transaction) => { + const boardValues = { + name: targetBoardName, + primaryColor: input.board.primaryColor, + secondaryColor: input.board.secondaryColor, + itemRadius: input.board.itemRadius, + }; + if (pendingBoardRow) { + transaction + .insert(boards) + .values({ ...pendingBoardRow, ...boardValues }) + .run(); + transaction + .insert(layouts) + .values( + pendingLayouts.map((layout) => + layout.id === baseLayout.id + ? { ...layout, columnCount: baseColumnCount, leftGutterColumnCount, rightGutterColumnCount } + : layout, + ), + ) + .run(); + } else { + transaction.update(boards).set(boardValues).where(eq(boards.id, targetBoard.id)).run(); + transaction + .update(layouts) + .set({ columnCount: baseColumnCount, leftGutterColumnCount, rightGutterColumnCount }) + .where(eq(layouts.id, baseLayout.id)) + .run(); + } + if (!emptySection) { + transaction + .insert(sections) + .values({ id: sectionId, boardId: targetBoard.id, kind: "empty", xOffset: 0, yOffset: 0 }) + .run(); + } + if (rootSectionsToInsert.length > 0) transaction.insert(sections).values(rootSectionsToInsert).run(); + if (newContainerSections.length > 0) { + transaction.insert(sections).values(newContainerSections).run(); + } + if (newSectionLayouts.length > 0) { + transaction.insert(sectionLayouts).values(newSectionLayouts).run(); + } + if (newItems.length > 0) transaction.insert(items).values(newItems).run(); + if (newItemLayouts.length > 0) transaction.insert(itemLayouts).values(newItemLayouts).run(); + if (newIntegrationItems.length > 0) transaction.insert(integrationItems).values(newIntegrationItems).run(); + for (const [settingKey, value] of Object.entries(settingValues)) { + transaction + .update(serverSettings) + .set({ value }) + .where(eq(serverSettings.settingKey, settingKey as keyof typeof settingValues)) + .run(); + } + transaction + .update(groups) + .set({ homeBoardId: targetBoard.id, mobileHomeBoardId: targetBoard.id }) + .where(eq(groups.name, everyoneGroup)) + .run(); + transaction.delete(serverSettings).where(eq(serverSettings.settingKey, onboardingClaimSettingKey)).run(); + transaction.update(onboarding).set({ previousStep: "setup", step: "finish" }).run(); + }); + }, + }); + + return { + boardId: targetBoard.id, + boardName: targetBoardName, + docker: { + missingSourceIds: missingDockerSourceIds, + ignoredSourceIds: [...ignoredDockerSourceIds], + skippedWidgets, + }, + }; + }), }); diff --git a/packages/api/src/router/serverSettings.ts b/packages/api/src/router/serverSettings.ts index 9783e0ae9..d49d4ad83 100644 --- a/packages/api/src/router/serverSettings.ts +++ b/packages/api/src/router/serverSettings.ts @@ -11,10 +11,8 @@ import { import { boards, serverSettings } from "@homarr/db/schema"; import type { ServerSettings } from "@homarr/server-settings"; import { defaultServerSettingsKeys } from "@homarr/server-settings"; -import { settingsInitSchema } from "@homarr/validation/settings"; -import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, publicProcedure } from "../trpc"; -import { nextOnboardingStepAsync } from "./onboard/onboard-queries"; +import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../trpc"; const boardServerSettingsSchema = z.object({ homeBoardId: z.string().nullable(), @@ -108,13 +106,4 @@ export const serverSettingsRouter = createTRPCRouter({ ...input.value, } as ServerSettings[keyof ServerSettings]); }), - initSettings: onboardingProcedure - .requiresStep("settings") - .input(settingsInitSchema) - .mutation(async ({ ctx, input }) => { - const currentAnalytics = await getServerSettingByKeyAsync(ctx.db, "analytics"); - await updateServerSettingByKeyAsync(ctx.db, "analytics", { ...currentAnalytics, ...input.analytics }); - await updateServerSettingByKeyAsync(ctx.db, "crawlingAndIndexing", input.crawlingAndIndexing); - await nextOnboardingStepAsync(ctx.db, undefined); - }), }); diff --git a/packages/api/src/router/test/app.spec.ts b/packages/api/src/router/test/app.spec.ts index 44d2f6ce5..3a30bacc9 100644 --- a/packages/api/src/router/test/app.spec.ts +++ b/packages/api/src/router/test/app.spec.ts @@ -76,23 +76,23 @@ describe("all should return all apps", () => { }); }); -describe("getPaginated should require board modification access", () => { - test("should throw FORBIDDEN without board-modify-all", async () => { +describe("getPaginated should return apps to authenticated users", () => { + test("should throw UNAUTHORIZED without a session", async () => { const caller = appRouter.createCaller({ db: createDb(), deviceType: undefined, - session: createDefaultSession(), + session: null, }); - await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied"); + await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("UNAUTHORIZED"); }); - test("should return apps with board-modify-all", async () => { + test("should return apps without board permissions", async () => { const db = createDb(); const caller = appRouter.createCaller({ db, deviceType: undefined, - session: createDefaultSession(["board-modify-all"]), + session: createDefaultSession(), }); await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" }); diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index dbbb7f25c..e349f16e9 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -287,6 +287,113 @@ describe("getAllBoards should return all boards accessable to the current user", ); }); +describe("getManageOverview", () => { + test("returns the same accessible board set as getAllBoards", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); + const otherUserId = await createRandomUserAsync(db); + const publicBoardId = createId(); + const creatorBoardId = createId(); + const directBoardId = createId(); + const groupBoardId = createId(); + const hiddenBoardId = createId(); + const groupId = createId(); + + await db.insert(users).values({ id: defaultCreatorId }); + await db.insert(boards).values([ + { id: publicBoardId, name: "public-overview", creatorId: otherUserId, isPublic: true }, + { id: creatorBoardId, name: "creator-overview", creatorId: defaultCreatorId, isPublic: false }, + { id: directBoardId, name: "direct-overview", creatorId: otherUserId, isPublic: false }, + { id: groupBoardId, name: "group-overview", creatorId: otherUserId, isPublic: false }, + { id: hiddenBoardId, name: "hidden-overview", creatorId: otherUserId, isPublic: false }, + ]); + await db.insert(boardUserPermissions).values({ + userId: defaultCreatorId, + boardId: directBoardId, + permission: "view", + }); + await db.insert(groups).values({ id: groupId, name: "overview-group", position: 1 }); + await db.insert(groupMembers).values({ userId: defaultCreatorId, groupId }); + await db.insert(boardGroupPermissions).values({ groupId, boardId: groupBoardId, permission: "view" }); + + const [summaries, overview] = await Promise.all([caller.getAllBoards(), caller.getManageOverview()]); + + expect(overview.map((board) => board.id).toSorted()).toEqual(summaries.map((board) => board.id).toSorted()); + expect(overview.map((board) => board.id)).not.toContain(hiddenBoardId); + }); + + test("returns one compact base-layout preview for each accessible board", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); + const boardId = createId(); + const hiddenBoardId = createId(); + const baseLayoutId = createId(); + const mobileLayoutId = createId(); + const rootSectionId = createId(); + const itemId = createId(); + + await db.insert(users).values({ id: defaultCreatorId }); + await db.insert(boards).values([ + { id: boardId, name: "overview", creatorId: defaultCreatorId, isPublic: false }, + { id: hiddenBoardId, name: "hidden", creatorId: await createRandomUserAsync(db), isPublic: false }, + ]); + await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, defaultCreatorId)); + await db.insert(layouts).values([ + { + id: mobileLayoutId, + name: "Mobile", + boardId, + columnCount: 3, + breakpoint: 0, + role: "mobile", + }, + { + id: baseLayoutId, + name: "Base", + boardId, + columnCount: 12, + breakpoint: 768, + role: "base", + }, + ]); + await db.insert(sections).values({ + id: rootSectionId, + boardId, + kind: "empty", + xOffset: 0, + yOffset: 0, + }); + await db.insert(items).values({ id: itemId, boardId, kind: "clock" }); + await db.insert(itemLayouts).values({ + itemId, + sectionId: rootSectionId, + layoutId: baseLayoutId, + xOffset: 2, + yOffset: 1, + width: 4, + height: 2, + }); + + const result = await caller.getManageOverview(); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + id: boardId, + isHome: true, + preview: { + layouts: [{ id: baseLayoutId, role: "base", columnCount: 12 }], + items: [ + { + id: itemId, + layouts: [{ layoutId: baseLayoutId, sectionId: rootSectionId, width: 4, height: 2 }], + }, + ], + }, + }); + expect(result[0]?.preview?.layouts).toHaveLength(1); + }); +}); + describe("createBoard should create a new board", () => { test("should create a new board with permission board-create", async () => { // Arrange diff --git a/packages/api/src/router/test/onboard.spec.ts b/packages/api/src/router/test/onboard.spec.ts new file mode 100644 index 000000000..a2bb27b90 --- /dev/null +++ b/packages/api/src/router/test/onboard.spec.ts @@ -0,0 +1,959 @@ +import { createHash } from "node:crypto"; + +import SuperJSON from "superjson"; +import { assert, beforeEach, describe, expect, it, vi } from "vitest"; + +import { eq, inArray } from "@homarr/db"; +import { + apps, + boards, + integrationItems, + integrationSecrets, + integrations, + itemLayouts, + items, + layouts, + onboarding, + sectionLayouts, + sections, + users, +} from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; +import type { WidgetKind } from "@homarr/definitions"; + +import { claimOnboardingAsync, isOnboardingClaimValidAsync } from "../../onboarding-claim"; +import * as integrationConnection from "../integration/integration-test-connection"; + +const listDiscoveredContainersAsync = vi.hoisted(() => vi.fn()); +const createIdMock = vi.hoisted(() => vi.fn()); +const stableAppIdForSource = (sourceId: string) => { + const digest = createHash("sha256").update(sourceId).digest("hex").slice(0, 32); + return `onboarding_app_${digest}`; +}; +const stableIntegrationIdForSource = (sourceId: string) => { + const digest = createHash("sha256").update(sourceId).digest("hex").slice(0, 32); + return `onboarding_integration_${digest}`; +}; + +vi.mock("@homarr/auth/server", () => ({ isProviderEnabled: () => false })); +vi.mock("@homarr/common", async (importOriginal) => ({ + ...(await importOriginal()), + createId: createIdMock, +})); +vi.mock("@homarr/docker", () => ({ + dockerLabels: { hide: "homarr.hide" }, + listDiscoveredContainersAsync, +})); + +import { onboardRouter } from "../onboard/onboard-router"; +import { normalizeOnboardingStep } from "../onboard/onboard-queries"; + +const createCaller = (db: ReturnType, onboardingClaimToken?: string) => + onboardRouter.createCaller({ db, deviceType: undefined, session: null, onboardingClaimToken }); + +const completionInput = (boardId: string, name = "Configured-board") => ({ + server: { + defaultLocale: "en" as const, + defaultColorScheme: "dark" as const, + analyticsEnabled: false, + }, + board: { + id: boardId, + name, + primaryColor: "#228BE6", + secondaryColor: "#15AABF", + itemRadius: "md" as const, + layoutPreset: "balanced" as const, + leftSidebar: false, + rightSidebar: false, + }, + selectedIntegrationIds: [] as string[], + selectedAppIds: [] as string[], + selectedDockerSourceIds: [] as string[], + selectedWidgetKinds: [] as WidgetKind[], +}); + +const rectanglesOverlap = ( + first: { xOffset: number; yOffset: number; width: number; height: number }, + second: { xOffset: number; yOffset: number; width: number; height: number }, +) => + first.xOffset < second.xOffset + second.width && + first.xOffset + first.width > second.xOffset && + first.yOffset < second.yOffset + second.height && + first.yOffset + first.height > second.yOffset; + +const seedOnboardingAsync = async (db: ReturnType) => { + await db.insert(onboarding).values({ id: "onboarding", step: "setup", previousStep: "group" }); + const claim = await claimOnboardingAsync(db); + if (claim.status !== "issued") throw new Error("Expected an onboarding claim"); + return claim.token; +}; + +const seedBoardAsync = async ( + db: ReturnType, + input: { boardId: string; itemId?: string; itemKind?: "notebook"; withMobile?: boolean }, +) => { + const layoutId = `${input.boardId}-base`; + const sectionId = `${input.boardId}-section`; + await db.insert(boards).values({ + id: input.boardId, + name: input.boardId, + primaryColor: "#FA5252", + secondaryColor: "#FD7E14", + }); + await db.insert(layouts).values({ + id: layoutId, + boardId: input.boardId, + name: "Base", + role: "base", + columnCount: 12, + breakpoint: 0, + }); + if (input.withMobile) { + await db.insert(layouts).values({ + id: `${input.boardId}-mobile`, + boardId: input.boardId, + name: "Mobile", + role: "mobile", + columnCount: 3, + breakpoint: 0, + }); + } + await db.insert(sections).values({ + id: sectionId, + boardId: input.boardId, + kind: "empty", + xOffset: 0, + yOffset: 0, + }); + if (input.itemId) { + await db.insert(items).values({ + id: input.itemId, + boardId: input.boardId, + kind: input.itemKind ?? "notebook", + options: SuperJSON.stringify({ content: "keep me" }), + }); + await db.insert(itemLayouts).values({ + itemId: input.itemId, + sectionId, + layoutId, + xOffset: 2, + yOffset: 3, + width: 4, + height: 2, + }); + } + return { layoutId, sectionId }; +}; + +describe("onboard.completeSetup", () => { + beforeEach(() => { + listDiscoveredContainersAsync.mockReset(); + createIdMock.mockReset(); + let nextId = 0; + createIdMock.mockImplementation(() => `generated-${++nextId}`); + }); + + it("updates only the exact target board and preserves existing content for explicit empty selections", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + const target = await seedBoardAsync(db, { boardId: "target", itemId: "existing-item" }); + await seedBoardAsync(db, { boardId: "other" }); + + const result = await createCaller(db, claimToken).completeSetup(completionInput("target")); + + expect(result).toEqual({ + boardId: "target", + boardName: "Configured-board", + docker: { missingSourceIds: [], ignoredSourceIds: [], skippedWidgets: [] }, + }); + expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({ + name: "Configured-board", + primaryColor: "#228BE6", + secondaryColor: "#15AABF", + itemRadius: "md", + }); + expect(await db.query.boards.findFirst({ where: eq(boards.id, "other") })).toMatchObject({ name: "other" }); + expect(await db.query.items.findMany()).toEqual([ + expect.objectContaining({ id: "existing-item", boardId: "target", kind: "notebook" }), + ]); + expect(await db.query.itemLayouts.findFirst({ where: eq(itemLayouts.itemId, "existing-item") })).toMatchObject({ + layoutId: target.layoutId, + xOffset: 2, + yOffset: 3, + width: 4, + height: 2, + }); + expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "finish", previousStep: "setup" }); + expect(await isOnboardingClaimValidAsync(db, claimToken)).toBe(false); + }); + + it("requires an exact board id when more than one board exists", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + await seedBoardAsync(db, { boardId: "target" }); + await seedBoardAsync(db, { boardId: "other" }); + const input = { + ...completionInput("target"), + board: { ...completionInput("target").board, id: undefined }, + }; + + await expect(createCaller(db, claimToken).completeSetup(input)).rejects.toThrow( + "Select the exact board to configure when more than one board exists.", + ); + expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({ name: "target" }); + expect(await db.query.boards.findFirst({ where: eq(boards.id, "other") })).toMatchObject({ name: "other" }); + expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" }); + }); + + it("creates a complete first board when setup resumes without any boards", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + const input = { + ...completionInput("missing"), + board: { + ...completionInput("missing").board, + id: undefined, + name: "Recovered-board", + leftSidebar: true, + rightSidebar: true, + }, + }; + + const result = await createCaller(db, claimToken).completeSetup(input); + + expect(await db.query.boards.findMany()).toEqual([ + expect.objectContaining({ + id: result.boardId, + name: "Recovered-board", + primaryColor: "#228BE6", + secondaryColor: "#15AABF", + itemRadius: "md", + }), + ]); + expect(await db.query.layouts.findMany({ where: eq(layouts.boardId, result.boardId) })).toEqual( + expect.arrayContaining([ + expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }), + expect.objectContaining({ + role: "base", + columnCount: 10, + breakpoint: 768, + leftGutterColumnCount: 1, + rightGutterColumnCount: 1, + }), + ]), + ); + expect(await db.query.sections.findMany({ where: eq(sections.boardId, result.boardId) })).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "empty", xOffset: 0, yOffset: 0 }), + expect.objectContaining({ kind: "empty", xOffset: -1, yOffset: 0 }), + expect.objectContaining({ kind: "empty", xOffset: 1, yOffset: 0 }), + ]), + ); + expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "finish", previousStep: "setup" }); + }); + + it("rolls back board and layout writes when planned item insertion fails", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + const { layoutId } = await seedBoardAsync(db, { boardId: "target", itemId: "generated-id" }); + const input = completionInput("target", "Must-roll-back"); + input.selectedWidgetKinds = ["clock"]; + createIdMock.mockReturnValue("generated-id"); + + await expect(createCaller(db, claimToken).completeSetup(input)).rejects.toThrow(); + + expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({ + name: "target", + primaryColor: "#FA5252", + secondaryColor: "#FD7E14", + }); + expect(await db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) })).toMatchObject({ columnCount: 12 }); + expect(await db.query.items.findMany()).toHaveLength(1); + expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" }); + expect(await isOnboardingClaimValidAsync(db, claimToken)).toBe(true); + }); + + it("retains partial Docker results and places an explicitly selected label widget", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + await seedBoardAsync(db, { boardId: "target" }); + const service = { + sourceId: "docker:good:clock", + containerId: "clock", + host: "good", + group: "Utilities", + name: "Clock service", + href: "http://clock:3000", + externalId: "clock", + widgetKind: "clock" as const, + }; + const integrationService = { + sourceId: "docker:good:sonarr", + containerId: "sonarr", + host: "good", + group: "Media", + name: "Sonarr", + href: "http://sonarr:8989", + externalId: "sonarr", + integrationKind: "sonarr" as const, + description: "TV automation", + pingUrl: "http://sonarr:8989/ping", + icon: "custom-sonarr.svg", + }; + listDiscoveredContainersAsync.mockResolvedValue({ + hosts: [ + { host: "good", status: "success", containers: [], services: [service, integrationService] }, + { host: "bad", status: "unavailable", reason: "permission denied", containers: [], services: [] }, + ], + services: [service, integrationService], + }); + + const discovery = await createCaller(db, claimToken).discoverDockerServices(); + + expect(discovery).toMatchObject({ + status: "partial", + hosts: [ + { host: "good", status: "success", containerCount: 0 }, + { host: "bad", status: "unavailable", reason: "permission denied", containerCount: 0 }, + ], + apps: [{ sourceId: service.sourceId, widgetKind: "clock" }], + integrations: [ + { + sourceId: integrationService.sourceId, + iconUrl: integrationService.icon, + description: integrationService.description, + pingUrl: integrationService.pingUrl, + }, + ], + }); + const input = completionInput("target"); + input.selectedWidgetKinds = discovery.apps.flatMap((app) => (app.widgetKind ? [app.widgetKind] : [])); + await createCaller(db, claimToken).completeSetup(input); + + expect(await db.query.items.findMany()).toEqual([expect.objectContaining({ boardId: "target", kind: "clock" })]); + }); + + it("persists discovery metadata on created apps and integration companion apps", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + const caller = createCaller(db, claimToken); + const appResult = await caller.createAppsFromDiscovery([ + { + sourceId: "docker:good:status", + name: "Status", + href: "http://status:3001", + pingUrl: "http://status:3001/health", + iconUrl: "https://icons.example/status.svg", + description: "Internal status page", + }, + ]); + const createdApp = appResult.apps[0]; + assert(createdApp); + expect(await db.query.apps.findFirst({ where: eq(apps.id, createdApp.id) })).toMatchObject({ + pingUrl: "http://status:3001/health", + iconUrl: "https://icons.example/status.svg", + description: "Internal status page", + }); + + const testConnection = vi.spyOn(integrationConnection, "testConnectionAsync").mockResolvedValue({ success: true }); + try { + const integrationResult = await caller.createIntegration({ + sourceId: "docker:good:sonarr", + name: "Sonarr", + url: "http://sonarr:8989", + kind: "sonarr", + secrets: [{ kind: "apiKey", value: "secret" }], + pingUrl: "http://sonarr:8989/ping", + iconUrl: "https://icons.example/sonarr.svg", + description: "TV automation", + }); + const createdAppId = "appId" in integrationResult ? integrationResult.appId : undefined; + expect(createdAppId).toBeDefined(); + if (!createdAppId) throw new Error("Expected integration creation to succeed"); + expect(await db.query.apps.findFirst({ where: eq(apps.id, createdAppId) })).toMatchObject({ + pingUrl: "http://sonarr:8989/ping", + iconUrl: "https://icons.example/sonarr.svg", + description: "TV automation", + }); + } finally { + testConnection.mockRestore(); + } + }); + + it("reuses a manual integration and companion app across retries", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + const caller = createCaller(db, claimToken); + const sourceId = "manual:sonarr"; + const createInput = { + sourceId, + name: "Sonarr", + url: "http://sonarr:8989", + kind: "sonarr" as const, + secrets: [{ kind: "apiKey" as const, value: "secret" }], + }; + const testConnection = vi.spyOn(integrationConnection, "testConnectionAsync").mockResolvedValue({ success: true }); + + try { + const first = await caller.createIntegration(createInput); + const second = await caller.createIntegration(createInput); + + expect(first).toEqual(second); + expect(first).toEqual({ + id: stableIntegrationIdForSource(sourceId), + appId: stableAppIdForSource(sourceId), + }); + expect(await db.query.integrations.findMany()).toHaveLength(1); + expect(await db.query.apps.findMany()).toHaveLength(1); + expect( + await db.query.integrationSecrets.findMany({ + where: eq(integrationSecrets.integrationId, stableIntegrationIdForSource(sourceId)), + }), + ).toHaveLength(1); + } finally { + testConnection.mockRestore(); + } + }); + + it("re-fetches selected labels into grouped responsive containers and ignores another board target", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + const { sectionId: mainSectionId } = await seedBoardAsync(db, { + boardId: "target", + itemId: "existing-item", + withMobile: true, + }); + const groupedSource = { + sourceId: "docker:good:sonarr", + containerId: "sonarr", + host: "good", + group: "Media", + name: "Sonarr", + href: "http://sonarr:8989", + externalId: "sonarr", + widgetKind: "clock" as const, + }; + const ignoredSource = { + ...groupedSource, + sourceId: "docker:good:radarr", + containerId: "radarr", + externalId: "radarr", + name: "Radarr", + boardName: "other-board", + }; + listDiscoveredContainersAsync.mockResolvedValue({ + hosts: [{ host: "good", status: "success", containers: [], services: [groupedSource, ignoredSource] }], + services: [groupedSource, ignoredSource], + }); + const groupedAppId = stableAppIdForSource(groupedSource.sourceId); + const ignoredAppId = stableAppIdForSource(ignoredSource.sourceId); + await db.insert(apps).values([ + { id: groupedAppId, name: groupedSource.name, iconUrl: "icon", href: groupedSource.href }, + { id: ignoredAppId, name: ignoredSource.name, iconUrl: "icon", href: ignoredSource.href }, + ]); + const input = completionInput("target"); + input.selectedAppIds = [groupedAppId, ignoredAppId]; + input.selectedDockerSourceIds = [groupedSource.sourceId, ignoredSource.sourceId, "docker:missing"]; + input.selectedWidgetKinds = ["clock"]; + + const result = await createCaller(db, claimToken).completeSetup(input); + + expect(result.docker).toEqual({ + missingSourceIds: ["docker:missing"], + ignoredSourceIds: [ignoredSource.sourceId], + skippedWidgets: [], + }); + const mediaSection = await db.query.sections.findFirst({ + where: (table, operators) => + operators.and(operators.eq(table.boardId, "target"), operators.eq(table.kind, "container")), + }); + expect(mediaSection).toBeDefined(); + expect(SuperJSON.parse(mediaSection?.options ?? "{}")).toMatchObject({ title: "Media", showLabel: true }); + const containerPlacements = await db.query.sectionLayouts.findMany({ + where: eq(sectionLayouts.sectionId, mediaSection?.id ?? "missing"), + }); + expect(containerPlacements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ layoutId: "target-base", parentSectionId: mainSectionId, width: 10 }), + expect.objectContaining({ layoutId: "target-mobile", parentSectionId: mainSectionId, width: 3 }), + ]), + ); + const groupedItems = await db.query.items.findMany({ + where: (table, operators) => operators.inArray(table.kind, ["app", "clock"]), + with: { layouts: true }, + }); + expect(groupedItems).toHaveLength(2); + expect(groupedItems.flatMap((item) => item.layouts)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ layoutId: "target-base", sectionId: mediaSection?.id }), + expect.objectContaining({ layoutId: "target-mobile", sectionId: mediaSection?.id }), + ]), + ); + expect(await db.query.items.findFirst({ where: eq(items.id, "existing-item") })).toBeDefined(); + expect( + groupedItems.some( + (item) => item.kind === "app" && SuperJSON.parse<{ appId: string }>(item.options).appId === ignoredAppId, + ), + ).toBe(false); + }); + + it("places a labeled integration app and linked widget together inside its group", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + await seedBoardAsync(db, { boardId: "target", withMobile: true }); + const source = { + sourceId: "docker:good:sonarr", + containerId: "sonarr", + host: "good", + group: "Media", + name: "Sonarr", + href: "http://sonarr:8989", + externalId: "sonarr", + integrationKind: "sonarr" as const, + widgetKind: "calendar" as const, + }; + listDiscoveredContainersAsync.mockResolvedValue({ + hosts: [{ host: "good", status: "success", containers: [], services: [source] }], + services: [source], + }); + const appId = stableAppIdForSource(source.sourceId); + const integrationId = stableIntegrationIdForSource(source.sourceId); + await db.insert(apps).values({ id: appId, name: source.name, iconUrl: "icon", href: source.href }); + await db.insert(integrations).values({ + id: integrationId, + appId, + name: source.name, + url: source.href, + kind: source.integrationKind, + }); + const input = completionInput("target"); + input.selectedIntegrationIds = [integrationId]; + input.selectedAppIds = [appId]; + input.selectedDockerSourceIds = [source.sourceId]; + input.selectedWidgetKinds = [source.widgetKind]; + + await createCaller(db, claimToken).completeSetup(input); + + const mediaSection = await db.query.sections.findFirst({ + where: (table, operators) => operators.eq(table.kind, "container"), + }); + const groupedItems = await db.query.items.findMany({ + where: (table, operators) => operators.inArray(table.kind, ["app", "calendar"]), + with: { layouts: true }, + }); + expect(groupedItems).toHaveLength(2); + expect(groupedItems.every((item) => item.layouts.every((layout) => layout.sectionId === mediaSection?.id))).toBe( + true, + ); + const calendar = groupedItems.find((item) => item.kind === "calendar"); + expect(calendar).toBeDefined(); + expect( + await db.query.integrationItems.findFirst({ where: eq(integrationItems.itemId, calendar?.id ?? "") }), + ).toEqual({ + itemId: calendar?.id, + integrationId, + }); + }); + + it("links a labeled integration to an existing compatible widget", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + const { sectionId, layoutId } = await seedBoardAsync(db, { boardId: "target", itemId: "existing-seed" }); + await db.insert(items).values({ id: "calendar", boardId: "target", kind: "calendar" }); + await db.insert(itemLayouts).values({ + itemId: "calendar", + sectionId, + layoutId, + xOffset: 0, + yOffset: 0, + width: 2, + height: 2, + }); + const source = { + sourceId: "docker:good:sonarr", + containerId: "sonarr", + host: "good", + group: "Media", + name: "Sonarr", + href: "http://sonarr:8989", + externalId: "sonarr", + integrationKind: "sonarr" as const, + widgetKind: "calendar" as const, + }; + listDiscoveredContainersAsync.mockResolvedValue({ + hosts: [{ host: "good", status: "success", containers: [], services: [source] }], + services: [source], + }); + const integrationId = stableIntegrationIdForSource(source.sourceId); + await db.insert(integrations).values({ + id: integrationId, + name: source.name, + url: source.href, + kind: source.integrationKind, + }); + const input = completionInput("target"); + input.selectedIntegrationIds = [integrationId]; + input.selectedDockerSourceIds = [source.sourceId]; + input.selectedWidgetKinds = [source.widgetKind]; + + await createCaller(db, claimToken).completeSetup(input); + + expect(await db.query.integrationItems.findFirst({ where: eq(integrationItems.itemId, "calendar") })).toEqual({ + itemId: "calendar", + integrationId, + }); + expect(await db.query.items.findMany({ where: eq(items.kind, "calendar") })).toHaveLength(1); + }); + + it("skips integration-required and incompatible label widgets but preserves their selected apps", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + await seedBoardAsync(db, { boardId: "target", itemId: "seeded-content" }); + await db.update(boards).set({ name: "dashboard" }).where(eq(boards.id, "target")); + const appOnly = { + sourceId: "docker:good:app-only", + containerId: "app-only", + host: "good", + group: "Media", + name: "App only", + href: "http://app-only:3000", + externalId: "app-only", + widgetKind: "mediaServer" as const, + }; + const incompatible = { + ...appOnly, + sourceId: "docker:good:sonarr", + containerId: "sonarr", + externalId: "sonarr", + name: "Sonarr", + href: "http://sonarr:8989", + integrationKind: "sonarr" as const, + }; + const services = [appOnly, incompatible]; + listDiscoveredContainersAsync.mockResolvedValue({ + hosts: [{ host: "good", status: "success", containers: [], services }], + services, + }); + const appIds = services.map((source) => stableAppIdForSource(source.sourceId)); + await db.insert(apps).values( + services.map((source) => ({ + id: stableAppIdForSource(source.sourceId), + name: source.name, + iconUrl: "icon", + href: source.href, + })), + ); + const integrationId = stableIntegrationIdForSource(incompatible.sourceId); + await db.insert(integrations).values({ + id: integrationId, + appId: appIds[1], + name: incompatible.name, + url: incompatible.href, + kind: incompatible.integrationKind, + }); + const input = completionInput("target"); + input.selectedAppIds = appIds; + input.selectedIntegrationIds = [integrationId]; + input.selectedDockerSourceIds = services.map((source) => source.sourceId); + input.selectedWidgetKinds = ["mediaServer"]; + + const result = await createCaller(db, claimToken).completeSetup(input); + + expect(result.docker.skippedWidgets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sourceId: appOnly.sourceId, code: "integration-required" }), + expect.objectContaining({ + sourceId: incompatible.sourceId, + code: "incompatible-integration", + integrationKind: "sonarr", + }), + ]), + ); + expect(await db.query.items.findMany({ where: eq(items.kind, "mediaServer") })).toHaveLength(0); + expect(await db.query.items.findMany({ where: eq(items.kind, "app") })).toHaveLength(2); + }); + + it("splits limited automatic integration widgets instead of over-linking one item", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + await seedBoardAsync(db, { boardId: "target" }); + await db.insert(integrations).values([ + { id: "navidrome", name: "Navidrome", url: "http://navidrome:4533", kind: "navidrome" }, + { id: "audiobookshelf", name: "Audiobookshelf", url: "http://audiobookshelf:13378", kind: "audiobookshelf" }, + ]); + const input = completionInput("target"); + input.selectedIntegrationIds = ["navidrome", "audiobookshelf"]; + + await createCaller(db, claimToken).completeSetup(input); + + const audioItems = await db.query.items.findMany({ where: eq(items.kind, "audioStats") }); + expect(audioItems).toHaveLength(2); + const links = await db.query.integrationItems.findMany({ + where: inArray( + integrationItems.itemId, + audioItems.map((item) => item.id), + ), + }); + expect(links).toHaveLength(2); + expect(new Set(links.map((link) => link.itemId)).size).toBe(2); + }); + + it("places an ungrouped labeled app and widget in the main root", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + await seedBoardAsync(db, { boardId: "target" }); + const source = { + sourceId: "docker:good:status", + containerId: "status", + host: "good", + name: "Status", + href: "http://status:3001", + externalId: "status", + widgetKind: "clock" as const, + }; + listDiscoveredContainersAsync.mockResolvedValue({ + hosts: [{ host: "good", status: "success", containers: [], services: [source] }], + services: [source], + }); + const appId = stableAppIdForSource(source.sourceId); + await db.insert(apps).values({ id: appId, name: source.name, iconUrl: "icon", href: source.href }); + const input = completionInput("target"); + input.selectedAppIds = [appId]; + input.selectedDockerSourceIds = [source.sourceId]; + input.selectedWidgetKinds = [source.widgetKind]; + + await createCaller(db, claimToken).completeSetup(input); + + expect(await db.query.sections.findMany({ where: eq(sections.kind, "container") })).toHaveLength(0); + expect(await db.query.items.findMany()).toEqual( + expect.arrayContaining([expect.objectContaining({ kind: "app" }), expect.objectContaining({ kind: "clock" })]), + ); + expect((await db.query.itemLayouts.findMany()).every((layout) => layout.sectionId === "target-section")).toBe(true); + }); + + it("packs grouped items with their actual dimensions without overlapping pending root content", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + await seedBoardAsync(db, { boardId: "target", withMobile: true }); + const sources = [ + { + sourceId: "docker:good:clock", + containerId: "clock", + host: "good", + group: "Utilities", + name: "Clock", + href: "http://clock:3000", + externalId: "clock", + widgetKind: "clock" as const, + }, + { + sourceId: "docker:good:weather", + containerId: "weather", + host: "good", + group: "Utilities", + name: "Weather", + href: "http://weather:3000", + externalId: "weather", + widgetKind: "weather" as const, + }, + ]; + listDiscoveredContainersAsync.mockResolvedValue({ + hosts: [{ host: "good", status: "success", containers: [], services: sources }], + services: sources, + }); + const appIds = sources.map((source) => stableAppIdForSource(source.sourceId)); + await db.insert(apps).values( + sources.map((source) => ({ + id: stableAppIdForSource(source.sourceId), + name: source.name, + iconUrl: "icon", + href: source.href, + })), + ); + const input = completionInput("target"); + input.board.leftSidebar = true; + input.board.rightSidebar = true; + input.selectedAppIds = appIds; + input.selectedDockerSourceIds = sources.map((source) => source.sourceId); + input.selectedWidgetKinds = ["bookmarks", "clock", "weather"]; + + await createCaller(db, claimToken).completeSetup(input); + + const container = await db.query.sections.findFirst({ where: (table, { eq }) => eq(table.kind, "container") }); + expect(container).toBeDefined(); + const containerLayouts = await db.query.sectionLayouts.findMany({ + where: eq(sectionLayouts.sectionId, container?.id ?? "missing"), + }); + const groupedLayouts = await db.query.itemLayouts.findMany({ + where: eq(itemLayouts.sectionId, container?.id ?? "missing"), + }); + for (const layoutId of ["target-base", "target-mobile"]) { + const positions = groupedLayouts.filter((layout) => layout.layoutId === layoutId); + const containerLayout = containerLayouts.find((layout) => layout.layoutId === layoutId); + expect(containerLayout).toBeDefined(); + expect(Math.max(...positions.map((position) => position.yOffset + position.height))).toBeLessThanOrEqual( + containerLayout?.height ?? 0, + ); + for (let firstIndex = 0; firstIndex < positions.length; firstIndex += 1) { + for (let secondIndex = firstIndex + 1; secondIndex < positions.length; secondIndex += 1) { + const firstPosition = positions[firstIndex]; + const secondPosition = positions[secondIndex]; + assert(firstPosition); + assert(secondPosition); + expect(rectanglesOverlap(firstPosition, secondPosition)).toBe(false); + } + } + } + const rootBookmarks = await db.query.itemLayouts.findMany({ + where: (table, { and, eq, inArray }) => + and(eq(table.sectionId, "target-section"), inArray(table.layoutId, ["target-base", "target-mobile"])), + }); + for (const containerLayout of containerLayouts) { + const rootItem = rootBookmarks.find((layout) => layout.layoutId === containerLayout.layoutId); + expect(rootItem).toBeDefined(); + assert(rootItem); + expect(rectanglesOverlap(rootItem, containerLayout)).toBe(false); + expect(containerLayout.width).toBeLessThanOrEqual(containerLayout.layoutId === "target-base" ? 8 : 3); + } + }); + + it("uses the main root and creates enabled sidebar roots transactionally", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + await db.insert(boards).values({ id: "target", name: "target" }); + await db.insert(layouts).values({ + id: "target-base", + boardId: "target", + name: "Base", + role: "base", + columnCount: 10, + breakpoint: 768, + }); + await db.insert(sections).values([ + { id: "right-root", boardId: "target", kind: "empty", xOffset: 1, yOffset: 0 }, + { id: "main-root", boardId: "target", kind: "empty", xOffset: 0, yOffset: 0 }, + ]); + const input = completionInput("target"); + input.board.leftSidebar = true; + input.board.rightSidebar = true; + input.selectedWidgetKinds = ["clock"]; + + await createCaller(db, claimToken).completeSetup(input); + + expect(await db.query.sections.findMany({ where: eq(sections.boardId, "target") })).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "main-root", xOffset: 0 }), + expect.objectContaining({ id: "right-root", xOffset: 1 }), + expect.objectContaining({ xOffset: -1, kind: "empty" }), + ]), + ); + expect(await db.query.itemLayouts.findFirst()).toMatchObject({ sectionId: "main-root" }); + expect(await db.query.layouts.findFirst({ where: eq(layouts.id, "target-base") })).toMatchObject({ + leftGutterColumnCount: 1, + rightGutterColumnCount: 1, + }); + }); + + it("adopts one label board name for a fresh sole board and ignores sources for other board targets", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + await seedBoardAsync(db, { boardId: "target", itemId: "seeded-content" }); + await db.update(boards).set({ name: "dashboard" }).where(eq(boards.id, "target")); + const media = { + sourceId: "docker:good:media", + containerId: "media", + host: "good", + group: "Media", + name: "Media", + href: "http://media:3000", + externalId: "media", + boardName: "Media board", + }; + listDiscoveredContainersAsync.mockResolvedValue({ + hosts: [{ host: "good", status: "success", containers: [], services: [media] }], + services: [media], + }); + const input = completionInput("target"); + input.selectedDockerSourceIds = [media.sourceId]; + + const result = await createCaller(db, claimToken).completeSetup(input); + + expect(result.boardName).toBe("Media-board"); + expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({ name: "Media-board" }); + expect(await db.query.items.findFirst({ where: eq(items.id, "seeded-content") })).toBeDefined(); + + const conflictDb = createDb(); + const conflictClaim = await seedOnboardingAsync(conflictDb); + await seedBoardAsync(conflictDb, { boardId: "target", itemId: "seeded-content" }); + await conflictDb.update(boards).set({ name: "dashboard" }).where(eq(boards.id, "target")); + const other = { ...media, sourceId: "docker:good:other", containerId: "other", boardName: "Other board" }; + const ignoredIntegrationSource = { + ...other, + sourceId: "docker:good:other-sonarr", + containerId: "other-sonarr", + externalId: "other-sonarr", + integrationKind: "sonarr" as const, + }; + listDiscoveredContainersAsync.mockResolvedValue({ + hosts: [{ host: "good", status: "success", containers: [], services: [media, other, ignoredIntegrationSource] }], + services: [media, other, ignoredIntegrationSource], + }); + const conflictInput = completionInput("target", "Media-board"); + const ignoredIntegrationId = stableIntegrationIdForSource(ignoredIntegrationSource.sourceId); + await conflictDb.insert(integrations).values({ + id: ignoredIntegrationId, + name: ignoredIntegrationSource.name, + url: ignoredIntegrationSource.href, + kind: ignoredIntegrationSource.integrationKind, + }); + conflictInput.selectedIntegrationIds = [ignoredIntegrationId]; + conflictInput.selectedDockerSourceIds = [media.sourceId, other.sourceId, ignoredIntegrationSource.sourceId]; + const conflictResult = await createCaller(conflictDb, conflictClaim).completeSetup(conflictInput); + expect(conflictResult.docker.ignoredSourceIds).toEqual([other.sourceId, ignoredIntegrationSource.sourceId]); + expect(await conflictDb.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({ + name: "Media-board", + }); + expect(await conflictDb.query.items.findMany({ where: eq(items.kind, "calendar") })).toHaveLength(0); + }); + + it("rejects setup mutations from an unclaimed browser", async () => { + const db = createDb(); + await seedOnboardingAsync(db); + await seedBoardAsync(db, { boardId: "target" }); + await expect(createCaller(db).completeSetup(completionInput("target"))).rejects.toThrow( + "This onboarding session is not claimed.", + ); + }); + + it("requires an authenticated administrator once any user exists", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + await seedBoardAsync(db, { boardId: "target" }); + await db.insert(users).values({ id: "owner", name: "owner" }); + await expect(createCaller(db, claimToken).completeSetup(completionInput("target"))).rejects.toThrow( + "This onboarding session is not claimed.", + ); + }); +}); + +describe("onboard.nextStep", () => { + it("advances welcome exactly once", async () => { + const db = createDb(); + await db.insert(onboarding).values({ id: "onboarding", step: "start" }); + const claim = await claimOnboardingAsync(db); + if (claim.status !== "issued") throw new Error("Expected an onboarding claim"); + const caller = createCaller(db, claim.token); + + await caller.nextStep(); + expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "start" }); + await expect(caller.nextStep()).rejects.toThrow("The welcome step is already complete."); + expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "start" }); + }); +}); + +describe("normalizeOnboardingStep", () => { + it("keeps legacy import backups eligible for restore from the start step", () => { + expect(normalizeOnboardingStep("import")).toBe("start"); + }); +}); diff --git a/packages/api/src/router/test/onboarding-claim.spec.ts b/packages/api/src/router/test/onboarding-claim.spec.ts new file mode 100644 index 000000000..9803ae59b --- /dev/null +++ b/packages/api/src/router/test/onboarding-claim.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { eq } from "@homarr/db"; +import { onboarding, serverSettings, users } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; + +import { + claimOnboardingAsync, + getOnboardingClaimTokenFromCookieHeader, + isOnboardingClaimValidAsync, + onboardingClaimCookieName, + onboardingClaimSettingKey, +} from "../../onboarding-claim"; + +describe("onboarding claim", () => { + it("stores only a token hash and validates the claimed browser", async () => { + const db = createDb(); + await db.insert(onboarding).values({ id: "onboarding", step: "start" }); + + const result = await claimOnboardingAsync(db, { now: 1_000, ttlMs: 10_000 }); + expect(result.status).toBe("issued"); + if (result.status !== "issued") throw new Error("Expected an issued claim"); + + const row = await db.query.serverSettings.findFirst({ + where: eq(serverSettings.settingKey, onboardingClaimSettingKey), + }); + expect(row?.value).not.toContain(result.token); + expect(await isOnboardingClaimValidAsync(db, result.token, 5_000)).toBe(true); + expect(await isOnboardingClaimValidAsync(db, "not-the-token", 5_000)).toBe(false); + }); + + it("does not let another browser replace a live claim", async () => { + const db = createDb(); + await db.insert(onboarding).values({ id: "onboarding", step: "start" }); + const issued = await claimOnboardingAsync(db, { now: 1_000, ttlMs: 10_000 }); + if (issued.status !== "issued") throw new Error("Expected an issued claim"); + + expect(await claimOnboardingAsync(db, { now: 2_000 })).toEqual({ + status: "locked", + expiresAt: 11_000, + }); + expect(await claimOnboardingAsync(db, { currentToken: issued.token, now: 2_000 })).toEqual({ + status: "active", + token: issued.token, + expiresAt: 11_000, + }); + }); + + it("allows a new claim after expiry and invalidates the old token", async () => { + const db = createDb(); + await db.insert(onboarding).values({ id: "onboarding", step: "start" }); + const first = await claimOnboardingAsync(db, { now: 1_000, ttlMs: 100 }); + if (first.status !== "issued") throw new Error("Expected an issued claim"); + + const second = await claimOnboardingAsync(db, { now: 1_101, ttlMs: 100 }); + if (second.status !== "issued") throw new Error("Expected a replacement claim"); + expect(second.token).not.toBe(first.token); + expect(await isOnboardingClaimValidAsync(db, first.token, 1_102)).toBe(false); + expect(await isOnboardingClaimValidAsync(db, second.token, 1_102)).toBe(true); + }); + + it("refuses claims after setup finishes", async () => { + const db = createDb(); + await db.insert(onboarding).values({ id: "onboarding", step: "finish", previousStep: "setup" }); + await expect(claimOnboardingAsync(db)).resolves.toEqual({ status: "finished" }); + }); + + it("requires an administrator to rotate claims after a user exists", async () => { + const db = createDb(); + await db.insert(onboarding).values({ id: "onboarding", step: "setup", previousStep: "user" }); + await db.insert(users).values({ id: "owner", name: "owner" }); + await expect(claimOnboardingAsync(db)).resolves.toEqual({ status: "forbidden" }); + await expect(claimOnboardingAsync(db, { force: true })).resolves.toMatchObject({ status: "issued" }); + }); + + it("extracts only the exact claim cookie", () => { + expect( + getOnboardingClaimTokenFromCookieHeader(`theme=dark; ${onboardingClaimCookieName}=secret-token; other=x`), + ).toBe("secret-token"); + expect(getOnboardingClaimTokenFromCookieHeader(`${onboardingClaimCookieName}-copy=wrong`)).toBeUndefined(); + }); +}); diff --git a/packages/api/src/router/test/user.spec.ts b/packages/api/src/router/test/user.spec.ts index 72ea8e70e..5129a3020 100644 --- a/packages/api/src/router/test/user.spec.ts +++ b/packages/api/src/router/test/user.spec.ts @@ -8,6 +8,7 @@ import { invites, onboarding, users } from "@homarr/db/schema"; import { createDb } from "@homarr/db/test"; import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions"; +import { claimOnboardingAsync } from "../../onboarding-claim"; import { userRouter } from "../user"; const defaultOwnerId = createId(); @@ -40,11 +41,12 @@ vi.mock("@homarr/auth/env", () => { describe("initUser should initialize the first user", () => { it("should create a user if none exists", async () => { const db = createDb(); - await createOnboardingStepAsync(db, "user"); + const onboardingClaimToken = await createOnboardingStepAsync(db, "user"); const caller = userRouter.createCaller({ db, deviceType: undefined, session: null, + onboardingClaimToken, }); await caller.initUser({ @@ -64,11 +66,12 @@ describe("initUser should initialize the first user", () => { it("should not create a user if the password and confirmPassword do not match", async () => { const db = createDb(); - await createOnboardingStepAsync(db, "user"); + const onboardingClaimToken = await createOnboardingStepAsync(db, "user"); const caller = userRouter.createCaller({ db, deviceType: undefined, session: null, + onboardingClaimToken, }); const actAsync = async () => @@ -83,11 +86,12 @@ describe("initUser should initialize the first user", () => { it.each([["aB2%"], ["short"]])("should reject passwords shorter than 8 characters for '%s'", async (password) => { const db = createDb(); - await createOnboardingStepAsync(db, "user"); + const onboardingClaimToken = await createOnboardingStepAsync(db, "user"); const caller = userRouter.createCaller({ db, deviceType: undefined, session: null, + onboardingClaimToken, }); const actAsync = async () => @@ -102,11 +106,12 @@ describe("initUser should initialize the first user", () => { it("should accept passwords without complexity requirements", async () => { const db = createDb(); - await createOnboardingStepAsync(db, "user"); + const onboardingClaimToken = await createOnboardingStepAsync(db, "user"); const caller = userRouter.createCaller({ db, deviceType: undefined, session: null, + onboardingClaimToken, }); await caller.initUser({ @@ -126,11 +131,12 @@ describe("initUser should initialize the first user", () => { it("should accept passwords with special characters like LoveHomarr<3", async () => { const db = createDb(); - await createOnboardingStepAsync(db, "user"); + const onboardingClaimToken = await createOnboardingStepAsync(db, "user"); const caller = userRouter.createCaller({ db, deviceType: undefined, session: null, + onboardingClaimToken, }); await caller.initUser({ @@ -439,4 +445,7 @@ const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) => id: createId(), step, }); + const claim = await claimOnboardingAsync(db); + if (claim.status !== "issued") throw new Error("Expected an onboarding claim"); + return claim.token; }; diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index a7e1735ee..1545b7402 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -4,12 +4,13 @@ import type { QueryResult } from "pg"; import { z } from "zod/v4"; import { comparePasswordsAsync, hashPasswordAsync } from "@homarr/auth"; +import { isProviderEnabled } from "@homarr/auth/server"; import { createId } from "@homarr/common"; import { createLogger } from "@homarr/core/infrastructure/logs"; import type { Database } from "@homarr/db"; import { and, eq, handleTransactionsAsync, inArray, like } from "@homarr/db"; import { getMaxGroupPositionAsync } from "@homarr/db/queries"; -import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema"; +import { boards, groupMembers, groupPermissions, groups, invites, onboarding, users } from "@homarr/db/schema"; import { selectUserSchema } from "@homarr/db/validationSchemas"; import type { SupportedAuthProvider } from "@homarr/definitions"; import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions"; @@ -41,7 +42,6 @@ import { } from "../trpc"; import { throwIfActionForbiddenAsync } from "./board/board-access"; import { throwIfCredentialsDisabled } from "./invite/checks"; -import { nextOnboardingStepAsync } from "./onboard/onboard-queries"; import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences"; const logger = createLogger({ module: "userRouter" }); @@ -52,25 +52,47 @@ export const userRouter = createTRPCRouter({ .input(userInitSchema) .mutation(async ({ ctx, input }) => { throwIfCredentialsDisabled(); + await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username); const maxPosition = await getMaxGroupPositionAsync(ctx.db); - const userId = await createUserAsync(ctx.db, input); + const hashedPassword = await hashPasswordAsync(input.password); + const userId = createId(); const groupId = createId(); - await ctx.db.insert(groups).values({ + const userRow = { + id: userId, + name: input.username, + email: input.email, + password: hashedPassword, + }; + const groupRow = { id: groupId, name: credentialsAdminGroup, ownerId: userId, position: maxPosition + 1, + }; + + await handleTransactionsAsync(ctx.db, { + async handleAsync(db, schema) { + await db.transaction(async (transaction) => { + await transaction.insert(schema.users).values(userRow); + await transaction.insert(schema.groups).values(groupRow); + await transaction.insert(schema.groupPermissions).values({ groupId, permission: "admin" }); + await transaction.insert(schema.groupMembers).values({ groupId, userId }); + const nextStep = isProviderEnabled("ldap") || isProviderEnabled("oidc") ? "group" : "setup"; + await transaction.update(schema.onboarding).set({ previousStep: "user", step: nextStep }); + }); + }, + handleSync(db) { + db.transaction((transaction) => { + transaction.insert(users).values(userRow).run(); + transaction.insert(groups).values(groupRow).run(); + transaction.insert(groupPermissions).values({ groupId, permission: "admin" }).run(); + transaction.insert(groupMembers).values({ groupId, userId }).run(); + const nextStep = isProviderEnabled("ldap") || isProviderEnabled("oidc") ? "group" : "setup"; + transaction.update(onboarding).set({ previousStep: "user", step: nextStep }).run(); + }); + }, }); - await ctx.db.insert(groupPermissions).values({ - groupId, - permission: "admin", - }); - await ctx.db.insert(groupMembers).values({ - groupId, - userId, - }); - await nextOnboardingStepAsync(ctx.db, undefined); }), register: publicProcedure .input(userRegistrationApiSchema) diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 2401a4650..2c0936dbb 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -20,6 +20,7 @@ import { db } from "@homarr/db"; import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions"; import { env } from "./env"; +import { getOnboardingClaimTokenFromCookieHeader, isClaimOnlyOnboardingAccessAllowedAsync } from "./onboarding-claim"; import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries"; import type { McpMeta } from "./mcp-tools"; @@ -41,6 +42,7 @@ interface ApiContext { session: Session | null; deviceType: DeviceType; baseUrl?: `${string}://${string}`; + onboardingClaimToken?: string; db: typeof db; } @@ -58,6 +60,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n session, deviceType: userAgent(opts.headers).device.type, baseUrl: extractBaseUrlFromHeaders(opts.headers), + onboardingClaimToken: getOnboardingClaimTokenFromCookieHeader(opts.headers.get("cookie")), db, }; }; @@ -129,6 +132,19 @@ const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly) */ export const publicProcedure = baseProcedure; +const enforceOnboardingAccess = t.middleware(async ({ ctx, next }) => { + const isAdmin = ctx.session?.user.permissions.includes("admin") ?? false; + if (!isAdmin) { + const hasValidClaim = await isClaimOnlyOnboardingAccessAllowedAsync(ctx.db, ctx.onboardingClaimToken); + if (!hasValidClaim) { + throw new TRPCError({ code: "FORBIDDEN", message: "This onboarding session is not claimed." }); + } + } + return next({ ctx }); +}); + +export const onboardingClaimedProcedure = baseProcedure.use(enforceOnboardingAccess); + export const internalProcedure = t.procedure; const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { @@ -177,7 +193,7 @@ export const permissionRequiredProcedure = { export const onboardingProcedure = { requiresStep: (step: OnboardingStep) => { - return publicProcedure.use(async ({ ctx, input, next }) => { + return onboardingClaimedProcedure.use(async ({ ctx, input, next }) => { const currentStep = await getOnboardingOrFallbackAsync(ctx.db).then(({ current }) => current); if (currentStep !== step) { throw new TRPCError({ diff --git a/packages/boards/package.json b/packages/boards/package.json index 057ac0550..a63b3cb49 100644 --- a/packages/boards/package.json +++ b/packages/boards/package.json @@ -14,6 +14,7 @@ "exports": { "./context": "./src/context.tsx", "./layout-selection": "./src/layout-selection.ts", + "./layout-preview": "./src/layout-preview.ts", "./updater": "./src/updater.ts", "./edit-mode": "./src/edit-mode.tsx" }, @@ -25,6 +26,8 @@ }, "dependencies": { "@homarr/api": "workspace:^0.1.0", + "@homarr/common": "workspace:^0.1.0", + "@homarr/definitions": "workspace:^0.1.0", "react": "catalog:", "react-dom": "catalog:" }, diff --git a/packages/boards/src/layout-preview.spec.ts b/packages/boards/src/layout-preview.spec.ts new file mode 100644 index 000000000..cc237e3fb --- /dev/null +++ b/packages/boards/src/layout-preview.spec.ts @@ -0,0 +1,215 @@ +import { describe, expect, test } from "vitest"; + +import type { BoardPreviewData, BoardPreviewLayout } from "./layout-preview"; +import { getRepresentativeLayoutWidth, projectBoardLayout } from "./layout-preview"; + +const mobile = { + id: "mobile", + columnCount: 3, + leftGutterColumnCount: 0, + rightGutterColumnCount: 0, + breakpoint: 0, + role: "mobile", +} satisfies BoardPreviewLayout; +const custom = { + id: "tablet", + columnCount: 6, + leftGutterColumnCount: 0, + rightGutterColumnCount: 0, + breakpoint: 480, + role: "custom", +} satisfies BoardPreviewLayout; +const base = { + id: "base", + columnCount: 12, + leftGutterColumnCount: 0, + rightGutterColumnCount: 0, + breakpoint: 768, + role: "base", +} satisfies BoardPreviewLayout; + +const root = { id: "root", kind: "empty" as const, xOffset: 0, layouts: [] }; + +describe("layout representative widths", () => { + test("uses phone, activation midpoint, and desktop widths", () => { + const layouts = [mobile, custom, base]; + expect(getRepresentativeLayoutWidth(mobile, layouts)).toBe(390); + expect(getRepresentativeLayoutWidth(custom, layouts)).toBe(624); + expect(getRepresentativeLayoutWidth(base, layouts)).toBe(1280); + }); +}); + +describe("projectBoardLayout", () => { + test("keeps exact saved positions for the active layout", () => { + const board = { + layouts: [mobile], + sections: [ + root, + { + id: "container", + kind: "container", + xOffset: null, + layouts: [ + { + layoutId: mobile.id, + parentSectionId: root.id, + width: 3, + height: 2, + xOffset: 0, + yOffset: 0, + }, + ], + }, + ], + items: [], + } satisfies BoardPreviewData; + + expect(projectBoardLayout(board, mobile, mobile)).toEqual([ + expect.objectContaining({ id: "container", sectionId: root.id, width: 3, height: 2 }), + ]); + }); + + test("reflows root items and containers while retaining nested items", () => { + const board = { + layouts: [mobile, base], + sections: [ + root, + { + id: "container", + kind: "container", + xOffset: null, + layouts: [ + { + layoutId: base.id, + parentSectionId: root.id, + width: 6, + height: 3, + xOffset: 6, + yOffset: 0, + }, + ], + }, + ], + items: [ + { + id: "root-item", + layouts: [ + { + layoutId: base.id, + sectionId: root.id, + width: 5, + height: 2, + xOffset: 0, + yOffset: 0, + }, + ], + }, + { + id: "nested-item", + layouts: [ + { + layoutId: base.id, + sectionId: "container", + width: 4, + height: 1, + xOffset: 0, + yOffset: 0, + }, + ], + }, + ], + } satisfies BoardPreviewData; + + const projected = projectBoardLayout(board, base, mobile); + + expect(projected.find((element) => element.id === "root-item")).toMatchObject({ + type: "item", + width: 3, + height: 2, + xOffset: 0, + yOffset: 0, + sectionId: root.id, + }); + expect(projected.find((element) => element.id === "container")).toMatchObject({ + type: "section", + width: 3, + xOffset: 0, + yOffset: 2, + sectionId: root.id, + }); + expect(projected.find((element) => element.id === "nested-item")).toMatchObject({ + type: "item", + width: 3, + sectionId: "container", + }); + }); + + test("ignores missing positions and supports empty boards", () => { + const board = { + layouts: [mobile], + sections: [root], + items: [{ id: "missing", layouts: [] }], + } satisfies BoardPreviewData; + + expect(projectBoardLayout(board, base, mobile)).toEqual([]); + }); + + test("moves fixed-rail items into the mobile canvas without overlap", () => { + const baseWithRails = { + ...base, + leftGutterColumnCount: 2, + rightGutterColumnCount: 2, + }; + const roots = [ + { id: "left", kind: "empty" as const, xOffset: -1, layouts: [] }, + root, + { id: "right", kind: "empty" as const, xOffset: 1, layouts: [] }, + ]; + const board = { + layouts: [mobile, baseWithRails], + sections: roots, + items: roots.map((section, index) => ({ + id: `item-${section.id}`, + layouts: [ + { + layoutId: base.id, + sectionId: section.id, + width: 2, + height: 1, + xOffset: 0, + yOffset: index, + }, + ], + })), + } satisfies BoardPreviewData; + + const projected = projectBoardLayout(board, baseWithRails, mobile); + + expect(projected).toHaveLength(3); + expect(projected.every((element) => element.sectionId === root.id)).toBe(true); + expect(projected.every((element) => element.xOffset >= 0 && element.xOffset + element.width <= 3)).toBe(true); + for (const [index, element] of projected.entries()) { + expect( + projected + .slice(index + 1) + .some( + (other) => + element.xOffset < other.xOffset + other.width && + element.xOffset + element.width > other.xOffset && + element.yOffset < other.yOffset + other.height && + element.yOffset + element.height > other.yOffset, + ), + ).toBe(false); + } + }); + + test("returns an empty preview when malformed data has no main root", () => { + const board = { + layouts: [mobile, base], + sections: [{ id: "left", kind: "empty", xOffset: -1, layouts: [] }], + items: [], + } satisfies BoardPreviewData; + + expect(projectBoardLayout(board, base, mobile)).toEqual([]); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-utils.ts b/packages/boards/src/layout-preview.ts similarity index 69% rename from apps/nextjs/src/app/[locale]/boards/_layout-utils.ts rename to packages/boards/src/layout-preview.ts index 8e1d45fe8..db5747031 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-utils.ts +++ b/packages/boards/src/layout-preview.ts @@ -1,14 +1,64 @@ import { generateResponsiveGridFor } from "@homarr/common"; import type { GridAlgorithmItem } from "@homarr/common"; -import type { BoardLane } from "@homarr/definitions"; +import type { BoardLane, LayoutRole } from "@homarr/definitions"; import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions"; -import type { Board } from "./_types"; +export interface BoardPreviewLayout { + id: string; + columnCount: number; + leftGutterColumnCount: number; + rightGutterColumnCount: number; + breakpoint: number; + role: LayoutRole; +} -export type BoardLayout = Board["layouts"][number]; -type LayoutGeometry = Pick; +interface BoardPreviewItemLayout { + layoutId: string; + sectionId: string; + xOffset: number; + yOffset: number; + width: number; + height: number; +} -export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: BoardLayout[]) => { +interface BoardPreviewSectionLayout { + layoutId: string; + parentSectionId: string | null; + xOffset: number; + yOffset: number; + width: number; + height: number; +} + +export interface BoardPreviewData { + layouts: readonly BoardPreviewLayout[]; + sections: readonly BoardPreviewSection[]; + items: readonly { + id: string; + layouts: readonly BoardPreviewItemLayout[]; + }[]; +} + +type BoardPreviewSection = + | { + id: string; + kind: "empty"; + xOffset: number | null; + layouts?: readonly BoardPreviewSectionLayout[]; + } + | { + id: string; + kind: "container"; + xOffset?: number | null; + layouts: readonly BoardPreviewSectionLayout[]; + }; + +type LayoutGeometry = Pick< + BoardPreviewLayout, + "id" | "columnCount" | "leftGutterColumnCount" | "rightGutterColumnCount" +>; + +export const getRepresentativeLayoutWidth = (layout: BoardPreviewLayout, allLayouts: readonly BoardPreviewLayout[]) => { if (layout.role === "mobile") return 390; if (layout.role === "base") return Math.max(1280, layout.breakpoint); @@ -20,7 +70,11 @@ export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: Bo return nextLayout ? Math.round((layout.breakpoint + nextLayout.breakpoint) / 2) : Math.max(1280, layout.breakpoint); }; -export const projectBoardLayout = (board: Board, sourceLayout: LayoutGeometry, targetLayout: LayoutGeometry) => { +export const projectBoardLayout = ( + board: BoardPreviewData, + sourceLayout: LayoutGeometry, + targetLayout: LayoutGeometry, +) => { const elements = getElementsForLayout(board, sourceLayout.id); if ( @@ -34,7 +88,7 @@ export const projectBoardLayout = (board: Board, sourceLayout: LayoutGeometry, t const emptyRoots = board.sections .filter((section) => section.kind === "empty") - .toSorted((first, second) => first.xOffset - second.xOffset || first.id.localeCompare(second.id)); + .toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id)); const rootByLane = new Map(); for (const root of emptyRoots) { const lane = getRootSectionLane(root.xOffset); @@ -85,7 +139,7 @@ export const projectBoardLayout = (board: Board, sourceLayout: LayoutGeometry, t }); }; -const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem[] => [ +const getElementsForLayout = (board: BoardPreviewData, layoutId: string): GridAlgorithmItem[] => [ ...board.items.flatMap((item) => { const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId); return layout diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index 5248b8892..3453a75fd 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -686,7 +686,7 @@ const seedDemoUserAsync = async (db: Database) => { await db.update(onboarding).set({ step: "finish", - previousStep: "settings", + previousStep: "setup", }); const integrationId = createId(); diff --git a/packages/definitions/src/integration-url-template.ts b/packages/definitions/src/integration-url-template.ts index bb8312c6e..2180c81e1 100644 --- a/packages/definitions/src/integration-url-template.ts +++ b/packages/definitions/src/integration-url-template.ts @@ -8,10 +8,24 @@ export type UrlTemplateMode = "hostPort" | "subdomain"; const getSlugForKind = (kind: IntegrationKind): string => integrationIconSlugs[kind]; -const buildUrl = (slug: string, host: string, mode: UrlTemplateMode, port?: number): string => { +const normalizeBaseHost = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const parsed = new URL(trimmed.includes("://") ? trimmed : `http://${trimmed}`); + return { hostname: parsed.hostname, port: parsed.port ? Number(parsed.port) : undefined }; + } catch { + return null; + } +}; + +const buildUrl = (slug: string, rawHost: string, mode: UrlTemplateMode, port?: number): string => { + const base = normalizeBaseHost(rawHost); + if (!base) return ""; + const effectivePort = port ?? base.port; const modeBuilders: Record string> = { - subdomain: () => `https://${slug}.${host}`, - hostPort: () => (port ? `http://${host}:${port}` : `http://${host}`), + subdomain: () => (base.hostname.startsWith("[") ? "" : `https://${slug}.${base.hostname}`), + hostPort: () => (effectivePort ? `http://${base.hostname}:${effectivePort}` : `http://${base.hostname}`), }; return modeBuilders[mode](); }; @@ -23,7 +37,6 @@ export const buildIntegrationUrl = ( dockerPort?: number, ): string => { const host = removeTrailingSlash(baseHost.trim()); - if (!host) return ""; const port = dockerPort ?? getIntegrationDefaultPort(kind); return buildUrl(getSlugForKind(kind), host, mode, port ?? undefined); }; @@ -35,7 +48,6 @@ export const buildAppUrl = ( 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 5450c9bda..85fad61a2 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -222,6 +222,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg", category: ["dnsHole"], documentationUrl: createDocumentationLink("/docs/integrations/technitium-dns"), + defaultPort: 5380, }, homeAssistant: { name: "Home Assistant", @@ -230,6 +231,7 @@ export const integrationDefs = { category: ["smartHomeServer", "calendar"], documentationUrl: createDocumentationLink("/docs/integrations/home-assistant"), defaultPort: 8123, + apiKeySettingsPath: "/profile/security", }, openmediavault: { name: "OpenMediaVault", @@ -245,13 +247,14 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png", category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/dash-dot"), + defaultPort: 3001, }, glances: { name: "Glances", secretKinds: [[]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg", category: ["healthMonitoring"], - documentationUrl: null, + documentationUrl: createDocumentationLink("/docs/integrations/glances"), defaultPort: 61208, }, tdarr: { @@ -293,6 +296,7 @@ export const integrationDefs = { category: ["firewall"], documentationUrl: createDocumentationLink("/docs/integrations/opnsense"), defaultPort: 443, + apiKeySettingsPath: "/system_usermanager.php", }, ntfy: { name: "ntfy", @@ -301,6 +305,7 @@ export const integrationDefs = { category: ["notifications"], documentationUrl: createDocumentationLink("/docs/integrations/ntfy"), defaultPort: 80, + apiKeySettingsPath: "/account", }, gotify: { name: "Gotify", @@ -308,6 +313,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gotify.svg", category: ["notifications"], documentationUrl: createDocumentationLink("/docs/integrations/gotify"), + defaultPort: 80, }, ical: { name: "iCal", @@ -323,6 +329,7 @@ export const integrationDefs = { category: ["notes"], documentationUrl: createDocumentationLink("/docs/integrations/anchor"), defaultPort: 8080, + apiKeySettingsPath: "/settings", }, truenas: { name: "TrueNAS", @@ -347,6 +354,7 @@ export const integrationDefs = { category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/unraid"), defaultPort: 80, + apiKeySettingsPath: "/Settings/ManagementAccess", }, coolify: { name: "Coolify", @@ -355,6 +363,7 @@ export const integrationDefs = { category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/coolify"), defaultPort: 8000, + apiKeySettingsPath: "/security/api-tokens", }, immich: { name: "Immich", @@ -363,13 +372,14 @@ export const integrationDefs = { category: ["photoService"], documentationUrl: createDocumentationLink("/docs/integrations/immich"), defaultPort: 2283, + apiKeySettingsPath: "/user-settings", }, paperlessNgx: { name: "Paperless-ngx", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/paperless-ngx.svg", category: ["documents"], - documentationUrl: null, + documentationUrl: createDocumentationLink("/docs/integrations/paperless-ngx"), defaultPort: 8000, }, patchmon: { @@ -388,6 +398,7 @@ export const integrationDefs = { category: ["mediaMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/tracearr"), defaultPort: 7040, + apiKeySettingsPath: "/settings", }, speedtestTracker: { name: "Speedtest Tracker", @@ -396,21 +407,23 @@ export const integrationDefs = { category: ["speedtest"], documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"), defaultPort: 80, + apiKeySettingsPath: "/admin/api-tokens", }, uptimeKuma: { name: "Uptime Kuma", secretKinds: [[], ["slug"], ["slug", "apiKey"], ["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg", category: ["uptimeMonitoring"], - documentationUrl: null, + documentationUrl: createDocumentationLink("/docs/integrations/uptime-kuma"), defaultPort: 3001, + apiKeySettingsPath: "/settings/api-keys", }, audiobookshelf: { name: "Audiobookshelf", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/audiobookshelf.svg", category: ["mediaLibrary"], - documentationUrl: null, + documentationUrl: createDocumentationLink("/docs/integrations/audiobookshelf"), defaultPort: 13378, apiKeySettingsPath: "/account", }, @@ -419,7 +432,7 @@ export const integrationDefs = { secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg", category: ["mediaLibrary", "mediaService"], - documentationUrl: null, + documentationUrl: createDocumentationLink("/docs/integrations/navidrome"), defaultPort: 4533, }, umami: { @@ -436,7 +449,8 @@ export const integrationDefs = { secretKinds: [["username", "password"], []], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg", category: ["ups"], - documentationUrl: null, + documentationUrl: createDocumentationLink("/docs/integrations/peanut"), + defaultPort: 8080, }, beszel: { name: "Beszel", @@ -452,6 +466,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg", category: ["vpn"], documentationUrl: createDocumentationLink("/docs/integrations/gluetun"), + defaultPort: 8000, }, traefik: { name: "Traefik", @@ -466,7 +481,8 @@ export const integrationDefs = { secretKinds: [[], ["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png", category: ["archiving"], - documentationUrl: null, + documentationUrl: createDocumentationLink("/docs/integrations/archiveteam-warrior"), + defaultPort: 8001, }, wud: { name: "What's Up Docker", @@ -524,6 +540,9 @@ export const getIntegrationDefaultPort = (kind: IntegrationKind): number | undef return "defaultPort" in definition ? definition.defaultPort : undefined; }; +export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | null => + integrationDefs[kind].documentationUrl; + export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => { const definition = integrationDefs[kind]; if (!("apiKeySettingsPath" in definition)) return null; diff --git a/packages/definitions/src/onboarding.ts b/packages/definitions/src/onboarding.ts index 305a71f65..f9d3bdf72 100644 --- a/packages/definitions/src/onboarding.ts +++ b/packages/definitions/src/onboarding.ts @@ -1,2 +1,5 @@ -export const onboardingSteps = ["start", "import", "user", "group", "settings", "integrations", "finish"] as const; +export const onboardingSteps = ["start", "user", "group", "setup", "finish"] as const; export type OnboardingStep = (typeof onboardingSteps)[number]; + +export const onboardingLayoutPresets = ["balanced", "wide", "focused"] as const; +export type OnboardingLayoutPreset = (typeof onboardingLayoutPresets)[number]; diff --git a/packages/definitions/src/test/api-key-url.spec.ts b/packages/definitions/src/test/api-key-url.spec.ts index 4e70c75ca..d9436ab59 100644 --- a/packages/definitions/src/test/api-key-url.spec.ts +++ b/packages/definitions/src/test/api-key-url.spec.ts @@ -1,8 +1,16 @@ import { describe, expect, it } from "vitest"; -import { getIntegrationApiKeyUrl } from "../integration"; +import { getIntegrationApiKeyUrl, integrationDefs } from "../integration"; describe("getIntegrationApiKeyUrl", () => { + it("links every user-facing integration to its Homarr setup guide", () => { + expect( + Object.entries(integrationDefs) + .filter(([kind, definition]) => kind !== "mock" && !definition.documentationUrl) + .map(([kind]) => kind), + ).toEqual([]); + }); + it("builds URL for sonarr", () => { expect(getIntegrationApiKeyUrl("http://192.168.1.10:8989", "sonarr")).toBe( "http://192.168.1.10:8989/settings/general", @@ -21,10 +29,28 @@ describe("getIntegrationApiKeyUrl", () => { ); }); - it("returns null for integrations without apiKeySettingsPath", () => { - expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull(); + it.each([ + ["homeAssistant", "http://homeassistant.local:8123", "http://homeassistant.local:8123/profile/security"], + ["opnsense", "https://firewall.local", "https://firewall.local/system_usermanager.php"], + ["ntfy", "https://ntfy.local", "https://ntfy.local/account"], + ["anchor", "http://anchor.local:8080", "http://anchor.local:8080/settings"], + ["unraid", "http://tower.local", "http://tower.local/Settings/ManagementAccess"], + ["coolify", "https://coolify.local", "https://coolify.local/security/api-tokens"], + ["immich", "http://immich.local:2283", "http://immich.local:2283/user-settings"], + ["tracearr", "http://tracearr.local:7040", "http://tracearr.local:7040/settings"], + ["speedtestTracker", "http://speedtest.local", "http://speedtest.local/admin/api-tokens"], + ["uptimeKuma", "http://uptime.local:3001", "http://uptime.local:3001/settings/api-keys"], + ] as const)("builds a verified credential URL for %s", (kind, baseUrl, expected) => { + expect(getIntegrationApiKeyUrl(baseUrl, kind)).toBe(expected); }); + it.each(["plex", "piHole", "truenas"] as const)( + "returns null when %s has no version-safe credential path", + (kind) => { + expect(getIntegrationApiKeyUrl("http://localhost", kind)).toBeNull(); + }, + ); + it("returns null for empty URL", () => { expect(getIntegrationApiKeyUrl("", "sonarr")).toBeNull(); }); diff --git a/packages/definitions/src/test/integration-url-template.spec.ts b/packages/definitions/src/test/integration-url-template.spec.ts index 05e7a4e9b..bebbd37ef 100644 --- a/packages/definitions/src/test/integration-url-template.spec.ts +++ b/packages/definitions/src/test/integration-url-template.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { getIntegrationDefaultPort } from "../integration"; -import { buildIntegrationUrl } from "../integration-url-template"; +import { buildAppUrl, buildIntegrationUrl } from "../integration-url-template"; describe("buildIntegrationUrl", () => { it("builds subdomain URL", () => { @@ -25,6 +25,31 @@ describe("buildIntegrationUrl", () => { expect(buildIntegrationUrl("sonarr", "homelab.local///", "subdomain")).toBe("https://sonarr.homelab.local"); }); + it("accepts full URLs but ignores their path when suggesting service URLs", () => { + expect(buildIntegrationUrl("sonarr", "https://home.example.com/homarr", "hostPort")).toBe( + "http://home.example.com:8989", + ); + expect(buildAppUrl("My Service", "http://home.example.com/path", "subdomain")).toBe( + "https://my-service.home.example.com", + ); + }); + + it("replaces a supplied base port instead of producing a double port", () => { + expect(buildIntegrationUrl("sonarr", "https://home.example.com:8443/homarr", "hostPort")).toBe( + "http://home.example.com:8989", + ); + expect(buildAppUrl("My Service", "https://home.example.com:8443/homarr", "hostPort")).toBe( + "http://home.example.com:8443", + ); + }); + + it("formats IPv6 hosts safely in host-port mode", () => { + expect(buildIntegrationUrl("sonarr", "https://[2001:db8::1]:8443/homarr", "hostPort")).toBe( + "http://[2001:db8::1]:8989", + ); + expect(buildIntegrationUrl("sonarr", "[2001:db8::1]", "subdomain")).toBe(""); + }); + it("returns empty string for empty host", () => { expect(buildIntegrationUrl("sonarr", "", "subdomain")).toBe(""); }); @@ -37,5 +62,11 @@ describe("buildIntegrationUrl", () => { expect(getIntegrationDefaultPort("radarr")).toBe(7878); expect(getIntegrationDefaultPort("prowlarr")).toBe(9696); expect(getIntegrationDefaultPort("jellyfin")).toBe(8096); + expect(getIntegrationDefaultPort("technitiumDns")).toBe(5380); + expect(getIntegrationDefaultPort("dashDot")).toBe(3001); + expect(getIntegrationDefaultPort("gotify")).toBe(80); + expect(getIntegrationDefaultPort("peaNut")).toBe(8080); + expect(getIntegrationDefaultPort("gluetun")).toBe(8000); + expect(getIntegrationDefaultPort("archiveTeamWarrior")).toBe(8001); }); }); diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index 250cb856f..25aa3c68c 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -63,6 +63,40 @@ export const widgetIntegrationLimits: Partial> = { audioStats: 1, }; +export type WidgetIntegrationIssue = + | { code: "integration-not-supported" } + | { code: "integration-required" } + | { code: "incompatible-integration"; incompatibleKinds: IntegrationKind[] } + | { code: "integration-limit"; limit: number }; + +export const getWidgetIntegrationIssue = ( + widgetKind: WidgetKind, + integrationKinds: readonly IntegrationKind[], +): WidgetIntegrationIssue | null => { + const supportedIntegrations = widgetIntegrationSupport[widgetKind]; + const integrationLimit = widgetIntegrationLimits[widgetKind] ?? Number.POSITIVE_INFINITY; + if (integrationKinds.length > integrationLimit) return { code: "integration-limit", limit: integrationLimit }; + if (supportedIntegrations === undefined && integrationKinds.length > 0) return { code: "integration-not-supported" }; + if ( + supportedIntegrations !== undefined && + integrationKinds.length === 0 && + !widgetKindsWithOptionalIntegrations.has(widgetKind) + ) { + return { code: "integration-required" }; + } + const incompatibleKinds = integrationKinds.filter((kind) => !supportedIntegrations?.includes(kind)); + return incompatibleKinds.length > 0 ? { code: "incompatible-integration", incompatibleKinds } : null; +}; + +export const getWidgetIntegrationIssueMessage = (widgetKind: WidgetKind, issue: WidgetIntegrationIssue) => { + if (issue.code === "integration-limit") { + return `${widgetKind} supports at most ${issue.limit} integration${issue.limit === 1 ? "" : "s"}`; + } + if (issue.code === "integration-not-supported") return `${widgetKind} does not support integrations`; + if (issue.code === "integration-required") return `${widgetKind} requires an integration`; + return `${widgetKind} does not support integration kind${issue.incompatibleKinds.length === 1 ? "" : "s"}: ${issue.incompatibleKinds.join(", ")}`; +}; + export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => { const result: WidgetKind[] = []; for (const [widgetKind, supportedIntegrations] of Object.entries(widgetIntegrationSupport)) { diff --git a/packages/docker/package.json b/packages/docker/package.json index a3d5816fd..c0a0ebc65 100644 --- a/packages/docker/package.json +++ b/packages/docker/package.json @@ -25,11 +25,13 @@ "dependencies": { "@homarr/common": "workspace:^0.1.0", "@homarr/core": "workspace:^0.1.0", + "@homarr/definitions": "workspace:^0.1.0", "dockerode": "catalog:" }, "devDependencies": { "@homarr/tsconfig": "workspace:^0.1.0", "@types/dockerode": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/docker/src/discovery/index.ts b/packages/docker/src/discovery/index.ts new file mode 100644 index 000000000..33a961350 --- /dev/null +++ b/packages/docker/src/discovery/index.ts @@ -0,0 +1,3 @@ +export { listDiscoveredContainersAsync } from "./list-discovered-containers"; +export { parseContainerLabels, type ParseContainerLabelsOptions } from "./parse-container-labels"; +export type { DiscoveredService, DockerDiscoveryHostResult, DockerDiscoveryResult } from "./types"; diff --git a/packages/docker/src/discovery/list-discovered-containers.spec.ts b/packages/docker/src/discovery/list-discovered-containers.spec.ts new file mode 100644 index 000000000..c7ad6b864 --- /dev/null +++ b/packages/docker/src/discovery/list-discovered-containers.spec.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const listContainersA = vi.fn(); +const listContainersB = vi.fn(); + +vi.mock("../singleton", () => ({ + DockerSingleton: { + getInstances: () => [ + { host: "good", instance: { listContainers: listContainersA } }, + { host: "bad", instance: { listContainers: listContainersB } }, + ], + }, +})); + +vi.mock("@homarr/core/infrastructure/logs", () => ({ + createLogger: () => ({ warn: vi.fn() }), +})); + +import { listDiscoveredContainersAsync } from "./list-discovered-containers"; + +describe("listDiscoveredContainersAsync", () => { + beforeEach(() => { + listContainersA.mockReset(); + listContainersB.mockReset(); + }); + + it("preserves successful hosts when another Docker host fails", async () => { + listContainersA.mockResolvedValue([ + { + Id: "sonarr", + Labels: { + "homarr.name": "Sonarr", + "homarr.group": "Media", + "homarr.href": "http://sonarr:8989", + }, + }, + ]); + listContainersB.mockRejectedValue(new Error("permission denied")); + + const result = await listDiscoveredContainersAsync(); + + expect(result.services).toHaveLength(1); + expect(result.hosts).toEqual([ + expect.objectContaining({ host: "good", status: "success" }), + { + host: "bad", + status: "unavailable", + reason: "permission denied", + containers: [], + services: [], + }, + ]); + }); + + it("times out a blackholed host without losing another host's result", async () => { + vi.useFakeTimers(); + try { + listContainersA.mockResolvedValue([ + { + Id: "sonarr", + Labels: { + "homarr.name": "Sonarr", + "homarr.group": "Media", + "homarr.href": "http://sonarr:8989", + }, + }, + ]); + listContainersB.mockReturnValue(new Promise(() => undefined)); + + const resultPromise = listDiscoveredContainersAsync({}, 25); + await vi.advanceTimersByTimeAsync(25); + const result = await resultPromise; + + expect(result.services).toHaveLength(1); + expect(result.hosts).toEqual([ + expect.objectContaining({ host: "good", status: "success" }), + expect.objectContaining({ host: "bad", status: "unavailable", reason: "Docker discovery timed out for bad" }), + ]); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/docker/src/discovery/list-discovered-containers.ts b/packages/docker/src/discovery/list-discovered-containers.ts new file mode 100644 index 000000000..8e5aaeed7 --- /dev/null +++ b/packages/docker/src/discovery/list-discovered-containers.ts @@ -0,0 +1,64 @@ +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; + +import { DockerSingleton } from "../singleton"; +import type { ParseContainerLabelsOptions } from "./parse-container-labels"; +import { parseContainerLabels } from "./parse-container-labels"; +import type { DockerDiscoveryHostResult, DockerDiscoveryResult } from "./types"; + +const logger = createLogger({ module: "dockerDiscovery" }); +export const dockerDiscoveryHostTimeoutMs = 5_000; + +const withTimeoutAsync = async (promise: Promise, timeoutMs: number, host: string) => { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(`Docker discovery timed out for ${host}`)), timeoutMs); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +}; + +const reasonFromError = (error: unknown) => { + if (error instanceof Error && error.message.trim()) return error.message; + return "Docker is not reachable"; +}; + +export const listDiscoveredContainersAsync = async ( + options: ParseContainerLabelsOptions = {}, + timeoutMs = dockerDiscoveryHostTimeoutMs, +): Promise => { + const dockerInstances = DockerSingleton.getInstances(); + const settled = await Promise.allSettled( + dockerInstances.map(async ({ instance, host }): Promise => { + const containers = await withTimeoutAsync(instance.listContainers({ all: false }), timeoutMs, host); + const services = containers + .map((container) => parseContainerLabels(container, host, options)) + .filter((service) => service !== null); + return { host, status: "success", containers, services }; + }), + ); + + const hosts = settled.map((result, index) => { + const host = dockerInstances[index]?.host ?? "unknown"; + if (result.status === "fulfilled") return result.value; + + logger.warn(new ErrorWithMetadata("Failed to list containers for discovery", { host }, { cause: result.reason })); + return { + host, + status: "unavailable", + reason: reasonFromError(result.reason), + containers: [], + services: [], + }; + }); + + return { + hosts, + services: hosts.flatMap((result) => result.services), + }; +}; diff --git a/packages/docker/src/discovery/parse-container-labels.spec.ts b/packages/docker/src/discovery/parse-container-labels.spec.ts new file mode 100644 index 000000000..3fc01c4b0 --- /dev/null +++ b/packages/docker/src/discovery/parse-container-labels.spec.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; + +import { parseContainerLabels } from "./parse-container-labels"; + +const container = (Labels: Record, Id = "container-1") => ({ Id, Labels }); + +describe("parseContainerLabels", () => { + it("parses complete Homarr labels", () => { + expect( + parseContainerLabels( + container({ + "homarr.name": "Sonarr", + "homarr.group": "Media", + "homarr.href": "http://sonarr:8989", + "homarr.icon": "sonarr.svg", + "homarr.description": "TV", + "homarr.ping": "http://sonarr:8989/ping", + "homarr.id": "sonarr-main", + "homarr.board": "dashboard", + "homarr.integration": "sonarr", + "homarr.widget": "calendar", + }), + "socket", + ), + ).toEqual({ + sourceId: "docker:socket:sonarr-main", + containerId: "container-1", + host: "socket", + group: "Media", + name: "Sonarr", + href: "http://sonarr:8989", + icon: "sonarr.svg", + description: "TV", + pingUrl: "http://sonarr:8989/ping", + externalId: "sonarr-main", + boardName: "dashboard", + integrationKind: "sonarr", + widgetKind: "calendar", + }); + }); + + it("supports Homepage labels when Homarr labels are absent", () => { + expect( + parseContainerLabels( + container({ + "homepage.name": "Jellyfin", + "homepage.group": "Media", + "homepage.href": "http://jellyfin:8096", + "homepage.icon": "jellyfin.svg", + }), + "remote:2375", + ), + ).toMatchObject({ + sourceId: "docker:remote:2375:container-1", + name: "Jellyfin", + group: "Media", + href: "http://jellyfin:8096", + icon: "jellyfin.svg", + }); + }); + + it("keeps a labeled service without a group for root placement", () => { + expect( + parseContainerLabels( + container({ + "homarr.name": "Status", + "homarr.href": "http://status:3001", + "homarr.widget": "clock", + }), + "socket", + ), + ).toMatchObject({ name: "Status", href: "http://status:3001", group: undefined, widgetKind: "clock" }); + }); + + it("does not mix Homepage required fields into an explicitly Homarr-labeled service", () => { + expect( + parseContainerLabels( + container({ + "homarr.name": "Sonarr", + "homepage.group": "Media", + "homepage.href": "http://sonarr:8989", + }), + "socket", + ), + ).toBeNull(); + }); + + it("can disable Homepage fallback", () => { + expect( + parseContainerLabels( + container({ + "homepage.name": "Sonarr", + "homepage.group": "Media", + "homepage.href": "http://sonarr:8989", + }), + "socket", + { readHomepageLabels: false }, + ), + ).toBeNull(); + }); + + it("ignores hidden and incomplete containers", () => { + expect( + parseContainerLabels( + container({ + "homarr.hide": "true", + "homarr.name": "Sonarr", + "homarr.group": "Media", + "homarr.href": "http://sonarr:8989", + }), + "socket", + ), + ).toBeNull(); + expect(parseContainerLabels(container({ "homarr.name": "Sonarr" }), "socket")).toBeNull(); + }); + + it("trims values and ignores invalid integration and widget kinds", () => { + expect( + parseContainerLabels( + container({ + "homarr.name": " Sonarr ", + "homarr.group": " Media ", + "homarr.href": " http://sonarr:8989 ", + "homarr.integration": "not-real", + "homarr.widget": "not-real", + }), + "socket", + ), + ).toMatchObject({ + name: "Sonarr", + group: "Media", + href: "http://sonarr:8989", + integrationKind: undefined, + widgetKind: undefined, + }); + }); +}); diff --git a/packages/docker/src/discovery/parse-container-labels.ts b/packages/docker/src/discovery/parse-container-labels.ts new file mode 100644 index 000000000..5e31ba763 --- /dev/null +++ b/packages/docker/src/discovery/parse-container-labels.ts @@ -0,0 +1,65 @@ +import type { ContainerInfo } from "dockerode"; + +import { integrationKinds, widgetKinds } from "@homarr/definitions"; +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; + +import { dockerLabels, homepageLabels } from "../labels"; +import type { DiscoveredService } from "./types"; + +export interface ParseContainerLabelsOptions { + readHomepageLabels?: boolean; +} + +const integrationKindSet = new Set(integrationKinds); +const widgetKindSet = new Set(widgetKinds); + +const readLabel = (labels: Record, key: string) => { + const value = labels[key]?.trim(); + return value ? value : undefined; +}; + +const resolveLabelValue = ( + labels: Record, + homarrKey: string, + homepageKey: string, + useHomepageFallback: boolean, +) => readLabel(labels, homarrKey) ?? (useHomepageFallback ? readLabel(labels, homepageKey) : undefined); + +const parseIntegrationKind = (value: string | undefined): IntegrationKind | undefined => + value && integrationKindSet.has(value) ? (value as IntegrationKind) : undefined; + +const parseWidgetKind = (value: string | undefined): WidgetKind | undefined => + value && widgetKindSet.has(value) ? (value as WidgetKind) : undefined; + +export const parseContainerLabels = ( + container: Pick, + host: string, + options: ParseContainerLabelsOptions = {}, +): DiscoveredService | null => { + const labels = container.Labels ?? {}; + if (dockerLabels.hide in labels) return null; + + const readHomepageLabels = options.readHomepageLabels ?? true; + const useHomepageFallback = readHomepageLabels && !readLabel(labels, dockerLabels.name); + const name = resolveLabelValue(labels, dockerLabels.name, homepageLabels.name, useHomepageFallback); + const href = resolveLabelValue(labels, dockerLabels.href, homepageLabels.href, useHomepageFallback); + const group = resolveLabelValue(labels, dockerLabels.group, homepageLabels.group, useHomepageFallback); + if (!name || !href) return null; + + const externalId = readLabel(labels, dockerLabels.id) ?? container.Id; + return { + sourceId: `docker:${host}:${externalId}`, + containerId: container.Id, + host, + group, + name, + href, + icon: resolveLabelValue(labels, dockerLabels.icon, homepageLabels.icon, useHomepageFallback), + description: resolveLabelValue(labels, dockerLabels.description, homepageLabels.description, useHomepageFallback), + pingUrl: readLabel(labels, dockerLabels.ping), + externalId, + boardName: readLabel(labels, dockerLabels.board), + integrationKind: parseIntegrationKind(readLabel(labels, dockerLabels.integration)), + widgetKind: parseWidgetKind(readLabel(labels, dockerLabels.widget)), + }; +}; diff --git a/packages/docker/src/discovery/types.ts b/packages/docker/src/discovery/types.ts new file mode 100644 index 000000000..b5ed71236 --- /dev/null +++ b/packages/docker/src/discovery/types.ts @@ -0,0 +1,39 @@ +import type { ContainerInfo } from "dockerode"; + +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; + +export interface DiscoveredService { + sourceId: string; + containerId: string; + host: string; + group?: string; + name: string; + href: string; + icon?: string; + description?: string; + pingUrl?: string; + externalId: string; + boardName?: string; + integrationKind?: IntegrationKind; + widgetKind?: WidgetKind; +} + +export type DockerDiscoveryHostResult = + | { + host: string; + status: "success"; + containers: ContainerInfo[]; + services: DiscoveredService[]; + } + | { + host: string; + status: "unavailable"; + reason: string; + containers: []; + services: []; + }; + +export interface DockerDiscoveryResult { + hosts: DockerDiscoveryHostResult[]; + services: DiscoveredService[]; +} diff --git a/packages/docker/src/index.ts b/packages/docker/src/index.ts index bd8f5dc2a..2a2b8012b 100644 --- a/packages/docker/src/index.ts +++ b/packages/docker/src/index.ts @@ -9,3 +9,4 @@ export const containerStates = ["created", "running", "paused", "restarting", "e export type ContainerState = (typeof containerStates)[number]; export * from "./labels"; +export * from "./discovery"; diff --git a/packages/docker/src/labels.ts b/packages/docker/src/labels.ts index 84bf0071f..45b60bce5 100644 --- a/packages/docker/src/labels.ts +++ b/packages/docker/src/labels.ts @@ -1,4 +1,21 @@ export const dockerLabels = { - // Label to hide a container from Homarrs docker integration hide: "homarr.hide", + group: "homarr.group", + name: "homarr.name", + href: "homarr.href", + icon: "homarr.icon", + description: "homarr.description", + ping: "homarr.ping", + id: "homarr.id", + board: "homarr.board", + integration: "homarr.integration", + widget: "homarr.widget", +} as const; + +export const homepageLabels = { + group: "homepage.group", + name: "homepage.name", + href: "homepage.href", + icon: "homepage.icon", + description: "homepage.description", } as const; diff --git a/packages/modals-collection/package.json b/packages/modals-collection/package.json index dbf66ffb6..40fc5bbbf 100644 --- a/packages/modals-collection/package.json +++ b/packages/modals-collection/package.json @@ -27,8 +27,6 @@ "@homarr/forms-collection": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0", - "@homarr/old-import": "workspace:^0.1.0", - "@homarr/old-schema": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", diff --git a/packages/modals-collection/src/apps/app-select-modal.tsx b/packages/modals-collection/src/apps/app-select-modal.tsx index 6ac726ac0..dc7fd415b 100644 --- a/packages/modals-collection/src/apps/app-select-modal.tsx +++ b/packages/modals-collection/src/apps/app-select-modal.tsx @@ -48,6 +48,7 @@ export const AppSelectModal = createModal(({ actions, inner search={search} onSearchChange={setSearch} placeholder={`${t("app.action.select.search")}...`} + ariaLabel={t("app.action.select.search")} onSearchKeyDown={(event) => { if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) { handleSelect(filteredApps[0]); diff --git a/packages/modals-collection/src/boards/import-board-modal.tsx b/packages/modals-collection/src/boards/import-board-modal.tsx deleted file mode 100644 index 3ddbd8324..000000000 --- a/packages/modals-collection/src/boards/import-board-modal.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useState } from "react"; -import { Button, FileInput, Group, Stack, TextInput } from "@mantine/core"; -import { IconFileUpload } from "@tabler/icons-react"; -import { z } from "zod/v4"; - -import { clientApi } from "@homarr/api/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { OldmarrImportAppsSettings, SidebarBehaviourSelect } from "@homarr/old-import/components"; -import type { OldmarrImportConfiguration } from "@homarr/old-import/shared"; -import { checkJsonImportFile, oldmarrImportConfigurationSchema } from "@homarr/old-import/shared"; -import { oldmarrConfigSchema } from "@homarr/old-schema"; -import { useScopedI18n } from "@homarr/translation/client"; - -import { useBoardNameStatus } from "./add-board-modal"; - -export const ImportBoardModal = createModal(({ actions }) => { - const tOldImport = useScopedI18n("board.action.oldImport"); - const tCommon = useScopedI18n("common"); - const [fileValid, setFileValid] = useState(true); - const form = useZodForm( - z.object({ - file: z.file().check(checkJsonImportFile), - configuration: oldmarrImportConfigurationSchema, - }), - { - mode: "controlled", - initialValues: { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - file: null!, - configuration: { - onlyImportApps: false, - sidebarBehaviour: "last-section", - name: "", - }, - }, - onValuesChange(values, previous) { - // This is a workarround until async validation is supported by mantine - void (async () => { - if (values.file === previous.file) { - return; - } - - // Before validation it can still be null - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!values.file) { - return; - } - - const content = await values.file.text(); - const result = oldmarrConfigSchema.safeParse(JSON.parse(content)); - - if (!result.success) { - console.error(result.error.issues); - setFileValid(false); - return; - } - - setFileValid(true); - form.setFieldValue("configuration.name", result.data.configProperties.name.replaceAll(" ", "-")); - })(); - }, - }, - ); - - const { mutateAsync, isPending } = clientApi.board.importOldmarrConfig.useMutation({ - async onSuccess() { - await revalidatePathActionAsync("/manage/boards"); - }, - }); - const boardNameStatus = useBoardNameStatus(form.values.configuration.name); - - const handleSubmitAsync = async (values: { file: File; configuration: OldmarrImportConfiguration }) => { - const formData = new FormData(); - formData.set("file", values.file); - formData.set("configuration", JSON.stringify(values.configuration)); - - await mutateAsync(formData, { - onSuccess() { - actions.closeModal(); - showSuccessNotification({ - title: tOldImport("notification.success.title"), - message: tOldImport("notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - title: tOldImport("notification.error.title"), - message: tOldImport("notification.error.message"), - }); - }, - }); - }; - - return ( -
{ - if (!fileValid || !boardNameStatus.canSubmit) { - return; - } - - void handleSubmitAsync({ - file: values.file, - configuration: values.configuration, - }); - })} - > - - } - withAsterisk - accept="application/json" - {...form.getInputProps("file")} - error={ - (form.getInputProps("file").error as string | undefined) ?? - (!fileValid && form.isDirty("file") ? tOldImport("form.file.invalidError") : undefined) - } - type="button" - label={tOldImport("form.file.label")} - /> - - - - - {boardNameStatus.description.icon ? : null} - {boardNameStatus.description.label} - - ) : null - } - {...form.getInputProps("configuration.name")} - /> - - - - - - - - -
- ); -}).withOptions({ - defaultTitle: (t) => t("board.action.oldImport.label"), - size: "lg", -}); diff --git a/packages/modals-collection/src/boards/index.ts b/packages/modals-collection/src/boards/index.ts index 8594bb424..ff591c8ac 100644 --- a/packages/modals-collection/src/boards/index.ts +++ b/packages/modals-collection/src/boards/index.ts @@ -1,3 +1,2 @@ export { AddBoardModal } from "./add-board-modal"; -export { ImportBoardModal } from "./import-board-modal"; export { DuplicateBoardModal } from "./duplicate-board-modal"; diff --git a/packages/old-import/index.ts b/packages/old-import/index.ts deleted file mode 100644 index 3bd16e178..000000000 --- a/packages/old-import/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./src"; diff --git a/packages/old-import/src/analyse/analyse-oldmarr-import.ts b/packages/old-import/src/analyse/analyse-oldmarr-import.ts deleted file mode 100644 index 2a440b979..000000000 --- a/packages/old-import/src/analyse/analyse-oldmarr-import.ts +++ /dev/null @@ -1,73 +0,0 @@ -import AdmZip from "adm-zip"; -import { z } from "zod/v4"; - -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { oldmarrConfigSchema } from "@homarr/old-schema"; - -import { oldmarrImportUserSchema } from "../user-schema"; -import type { analyseOldmarrImportInputSchema } from "./input"; - -const logger = createLogger({ module: "analyseOldmarrImport" }); - -export const analyseOldmarrImportForRouterAsync = async (input: z.infer) => { - const { configs, checksum, users } = await analyseOldmarrImportAsync(input.file); - - return { - configs, - checksum, - userCount: users.length, - }; -}; - -export const analyseOldmarrImportAsync = async (file: File) => { - const arrayBuffer = await file.arrayBuffer(); - const zip = new AdmZip(Buffer.from(arrayBuffer)); - const entries = zip.getEntries(); - const configEntries = entries.filter((entry) => entry.entryName.endsWith(".json") && !entry.entryName.includes("/")); - const configs = configEntries.map((entry) => { - const result = oldmarrConfigSchema.safeParse(JSON.parse(entry.getData().toString())); - if (!result.success) { - logger.error( - new ErrorWithMetadata( - "Failed to parse oldmarr config", - { entryName: entry.entryName }, - { cause: result.error }, - ), - ); - } - - return { - name: entry.name.replaceAll(" ", "-").replace(".json", ""), - config: result.data ?? null, - isError: !result.success, - }; - }); - - const userEntry = entries.find((entry) => entry.entryName === "users/users.json"); - const users = parseUsers(userEntry); - - const checksum = entries - .find((entry) => entry.entryName === "checksum.txt") - ?.getData() - .toString("utf-8"); - - return { - configs, - users, - checksum, - }; -}; - -export type AnalyseResult = Awaited>; - -const parseUsers = (entry: AdmZip.IZipEntry | undefined) => { - if (!entry) return []; - - const result = z.array(oldmarrImportUserSchema).safeParse(JSON.parse(entry.getData().toString())); - if (!result.success) { - logger.error(new Error("Failed to parse users", { cause: result.error })); - } - - return result.data ?? []; -}; diff --git a/packages/old-import/src/analyse/index.ts b/packages/old-import/src/analyse/index.ts deleted file mode 100644 index 1ec4b66e2..000000000 --- a/packages/old-import/src/analyse/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./input"; -export { analyseOldmarrImportForRouterAsync } from "./analyse-oldmarr-import"; diff --git a/packages/old-import/src/analyse/input.ts b/packages/old-import/src/analyse/input.ts deleted file mode 100644 index 876ca2d7e..000000000 --- a/packages/old-import/src/analyse/input.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { zfd } from "zod-form-data"; - -export const analyseOldmarrImportInputSchema = zfd.formData({ - file: zfd.file(), -}); diff --git a/packages/old-import/src/analyse/types.ts b/packages/old-import/src/analyse/types.ts deleted file mode 100644 index 691eed49a..000000000 --- a/packages/old-import/src/analyse/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Modify } from "@homarr/common/types"; -import type { OldmarrConfig } from "@homarr/old-schema"; - -import type { AnalyseResult } from "./analyse-oldmarr-import"; - -export type AnalyseConfig = AnalyseResult["configs"][number]; -export type ValidAnalyseConfig = Modify; diff --git a/packages/old-import/src/components/index.ts b/packages/old-import/src/components/index.ts deleted file mode 100644 index 98f86767b..000000000 --- a/packages/old-import/src/components/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { InitialOldmarrImport } from "./initial-oldmarr-import"; -export { SidebarBehaviourSelect } from "./shared/sidebar-behaviour-select"; -export { OldmarrImportAppsSettings } from "./shared/apps-section"; diff --git a/packages/old-import/src/components/initial-oldmarr-import.tsx b/packages/old-import/src/components/initial-oldmarr-import.tsx deleted file mode 100644 index 92aa301a5..000000000 --- a/packages/old-import/src/components/initial-oldmarr-import.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useMemo, useState } from "react"; -import { Stack } from "@mantine/core"; -import SuperJSON from "superjson"; - -import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useModalAction } from "@homarr/modals"; - -// We don't have access to the API client here, so we need to import it from the API package -// In the future we should consider having the used router also in this package -import { clientApi } from "../../../api/src/client"; -import type { AnalyseResult } from "../analyse/analyse-oldmarr-import"; -import { prepareMultipleImports } from "../prepare/prepare-multiple"; -import type { InitialOldmarrImportSettings } from "../settings"; -import { defaultSidebarBehaviour } from "../settings"; -import { BoardSelectionCard } from "./initial/board-selection-card"; -import { ImportSettingsCard } from "./initial/import-settings-card"; -import { ImportSummaryCard } from "./initial/import-summary-card"; -import { ImportTokenModal } from "./initial/token-modal"; - -interface InitialOldmarrImportProps { - file: File; - analyseResult: AnalyseResult; -} - -export const InitialOldmarrImport = ({ file, analyseResult }: InitialOldmarrImportProps) => { - const [boardSelections, setBoardSelections] = useState>( - new Map(analyseResult.configs.filter(({ config }) => config !== null).map(({ name }) => [name, true])), - ); - const [settings, setSettings] = useState({ - onlyImportApps: false, - sidebarBehaviour: defaultSidebarBehaviour, - }); - - const { preparedApps, preparedBoards, preparedIntegrations } = useMemo( - () => prepareMultipleImports(analyseResult.configs, settings, boardSelections), - [analyseResult, boardSelections, settings], - ); - - const { mutateAsync, isPending } = clientApi.import.importInitialOldmarrImport.useMutation({ - async onSuccess() { - await revalidatePathActionAsync("/init"); - }, - }); - const { openModal } = useModalAction(ImportTokenModal); - - const createFormData = (token: string | null) => { - const formData = new FormData(); - formData.set("file", file); - formData.set("settings", JSON.stringify(settings)); - // Map can not be send over the wire without superjson - formData.set("boardSelections", SuperJSON.stringify(boardSelections)); - if (token) { - formData.set("token", token); - } - return formData; - }; - - const handleSubmitAsync = async () => { - if (analyseResult.checksum) { - openModal({ - checksum: analyseResult.checksum, - onSuccessAsync: async (token) => { - await mutateAsync(createFormData(token)); - }, - }); - return; - } - await mutateAsync(createFormData(null)); - }; - - return ( - - { - setSettings((settings) => ({ ...settings, [setting]: value })); - }} - /> - {settings.onlyImportApps ? null : ( - - )} - - - ); -}; diff --git a/packages/old-import/src/components/initial/board-selection-card.tsx b/packages/old-import/src/components/initial/board-selection-card.tsx deleted file mode 100644 index 6f178f669..000000000 --- a/packages/old-import/src/components/initial/board-selection-card.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import type { ChangeEvent } from "react"; -import { Anchor, Card, Checkbox, Group, Stack, Text } from "@mantine/core"; - -import { useI18n, useScopedI18n } from "@homarr/translation/client"; - -export type BoardSelectionMap = Map; - -interface BoardSelectionCardProps { - selections: BoardSelectionMap; - updateSelections: (callback: (selections: BoardSelectionMap) => BoardSelectionMap) => void; -} - -const allChecked = (map: BoardSelectionMap) => { - return [...map.values()].every((selection) => selection); -}; - -export const BoardSelectionCard = ({ selections, updateSelections }: BoardSelectionCardProps) => { - const tBoardSelection = useScopedI18n("init.step.import.boardSelection"); - const t = useI18n(); - const areAllChecked = allChecked(selections); - - const handleToggleAll = () => { - updateSelections((selections) => { - return new Map([...selections.keys()].map((name) => [name, !areAllChecked] as const)); - }); - }; - - const registerToggle = (name: string) => (event: ChangeEvent) => { - updateSelections((selections) => { - const updated = new Map(selections); - updated.set(name, event.target.checked); - return updated; - }); - }; - - if (selections.size === 0) { - return null; - } - - return ( - - - - - {tBoardSelection("title", { count: String(selections.size) })} - - {areAllChecked ? tBoardSelection("action.unselectAll") : tBoardSelection("action.selectAll")} - - - - {tBoardSelection("description")} - - - {t("board.action.oldImport.form.screenSize.description")} - - - - - {[...selections.entries()].map(([name, selected]) => ( - - - {name} - - } - /> - - ))} - - - - ); -}; diff --git a/packages/old-import/src/components/initial/import-settings-card.tsx b/packages/old-import/src/components/initial/import-settings-card.tsx deleted file mode 100644 index f230ff921..000000000 --- a/packages/old-import/src/components/initial/import-settings-card.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Card, Stack, Text } from "@mantine/core"; - -import { useScopedI18n } from "@homarr/translation/client"; - -import type { InitialOldmarrImportSettings } from "../../settings"; -import { OldmarrImportAppsSettings } from "../shared/apps-section"; -import { SidebarBehaviourSelect } from "../shared/sidebar-behaviour-select"; - -interface ImportSettingsCardProps { - settings: InitialOldmarrImportSettings; - updateSetting: ( - setting: TKey, - value: InitialOldmarrImportSettings[TKey], - ) => void; -} - -export const ImportSettingsCard = ({ settings, updateSetting }: ImportSettingsCardProps) => { - const tImportSettings = useScopedI18n("init.step.import.importSettings"); - return ( - - - - {tImportSettings("title")} - - {tImportSettings("description")} - - - - updateSetting("onlyImportApps", event.target.checked), - }} - /> - - updateSetting("sidebarBehaviour", value)} - /> - - - ); -}; diff --git a/packages/old-import/src/components/initial/import-summary-card.tsx b/packages/old-import/src/components/initial/import-summary-card.tsx deleted file mode 100644 index 1000b35a6..000000000 --- a/packages/old-import/src/components/initial/import-summary-card.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Button, Card, Group, Stack, Text } from "@mantine/core"; - -import { objectEntries } from "@homarr/common"; -import type { MaybePromise } from "@homarr/common/types"; -import { useScopedI18n } from "@homarr/translation/client"; - -interface ImportSummaryCardProps { - counts: { apps: number; boards: number; integrations: number; credentialUsers: number }; - loading: boolean; - onSubmit: () => MaybePromise; -} - -export const ImportSummaryCard = ({ counts, onSubmit, loading }: ImportSummaryCardProps) => { - const tSummary = useScopedI18n("init.step.import.summary"); - return ( - - - - {tSummary("title")} - - {tSummary("description")} - - - - - {objectEntries(counts).map(([key, count]) => ( - - - - {tSummary(`entities.${key}`)} - - {count} - - - ))} - - - - - - ); -}; diff --git a/packages/old-import/src/components/initial/token-modal.tsx b/packages/old-import/src/components/initial/token-modal.tsx deleted file mode 100644 index 0bc5920f5..000000000 --- a/packages/old-import/src/components/initial/token-modal.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Button, Group, PasswordInput, Stack } from "@mantine/core"; -import { z } from "zod/v4"; - -import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { showErrorNotification } from "@homarr/notifications"; -import { useI18n, useScopedI18n } from "@homarr/translation/client"; - -// We don't have access to the API client here, so we need to import it from the API package -// In the future we should consider having the used router also in this package -import { clientApi } from "../../../../api/src/client"; - -interface InnerProps { - checksum: string; - onSuccessAsync: (token: string) => Promise; -} - -const formSchema = z.object({ - token: z.string().min(1).max(256), -}); - -export const ImportTokenModal = createModal(({ actions, innerProps }) => { - const t = useI18n(); - const tTokenModal = useScopedI18n("init.step.import.tokenModal"); - const { mutate, isPending } = clientApi.import.validateToken.useMutation(); - const form = useZodForm(formSchema, { initialValues: { token: "" } }); - - const handleSubmit = (values: z.infer) => { - mutate( - { checksum: innerProps.checksum, token: values.token }, - { - onSuccess(isValid) { - if (isValid) { - actions.closeModal(); - void innerProps.onSuccessAsync(values.token); - } else { - showErrorNotification({ - title: tTokenModal("notification.error.title"), - message: tTokenModal("notification.error.message"), - }); - } - }, - }, - ); - }; - - return ( -
- - - - - - - -
- ); -}).withOptions({ defaultTitle: (t) => t("init.step.import.tokenModal.title") }); diff --git a/packages/old-import/src/components/shared/apps-section.tsx b/packages/old-import/src/components/shared/apps-section.tsx deleted file mode 100644 index 23fef2cc9..000000000 --- a/packages/old-import/src/components/shared/apps-section.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Fieldset, Switch } from "@mantine/core"; - -import type { CheckboxProps } from "@homarr/form/types"; -import { useScopedI18n } from "@homarr/translation/client"; - -interface OldmarrImportAppsSettingsProps { - onlyImportApps: CheckboxProps; - background?: string; -} - -export const OldmarrImportAppsSettings = ({ background, onlyImportApps }: OldmarrImportAppsSettingsProps) => { - const tApps = useScopedI18n("board.action.oldImport.form.apps"); - - return ( -
- -
- ); -}; diff --git a/packages/old-import/src/components/shared/sidebar-behaviour-select.tsx b/packages/old-import/src/components/shared/sidebar-behaviour-select.tsx deleted file mode 100644 index 17a0e2c50..000000000 --- a/packages/old-import/src/components/shared/sidebar-behaviour-select.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { InputPropsFor } from "@homarr/form/types"; -import { useScopedI18n } from "@homarr/translation/client"; -import { SelectWithDescription } from "@homarr/ui"; - -import type { SidebarBehaviour } from "../../settings"; - -export const SidebarBehaviourSelect = (props: InputPropsFor) => { - const tSidebarBehaviour = useScopedI18n("board.action.oldImport.form.sidebarBehavior"); - - return ( - (value ? props.onChange(value as SidebarBehaviour) : null)} - /> - ); -}; diff --git a/packages/old-import/src/fix-section-issues.ts b/packages/old-import/src/fix-section-issues.ts deleted file mode 100644 index 0a092deec..000000000 --- a/packages/old-import/src/fix-section-issues.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createId } from "@homarr/common"; -import { createLogger } from "@homarr/core/infrastructure/logs"; -import type { OldmarrConfig } from "@homarr/old-schema"; - -const logger = createLogger({ module: "fixSectionIssues" }); - -export const fixSectionIssues = (old: OldmarrConfig) => { - const wrappers = old.wrappers.sort((wrapperA, wrapperB) => wrapperA.position - wrapperB.position); - const categories = old.categories.sort((categoryA, categoryB) => categoryA.position - categoryB.position); - - const neededSectionCount = categories.length * 2 + 1; - const hasToMuchEmptyWrappers = wrappers.length > categories.length + 1; - - logger.debug("Fixing section issues", { - neededSectionCount, - hasToMuchEmptyWrappers, - }); - - for (let position = 0; position < neededSectionCount; position++) { - const index = Math.floor(position / 2); - const isEmpty = position % 2 === 0; - const section = isEmpty ? wrappers[index] : categories[index]; - if (!section) { - // If there are not enough empty sections for categories we need to insert them - if (isEmpty) { - // Insert empty wrapper for between categories - wrappers.push({ - id: createId(), - position, - }); - } - continue; - } - - section.position = position; - } - - // Find all wrappers that should be merged into one - const wrapperIdsToMerge = wrappers.slice(categories.length).map((section) => section.id); - // Remove all wrappers after the first at the end - wrappers.splice(categories.length + 1); - - if (wrapperIdsToMerge.length >= 2) { - logger.debug("Found wrappers to merge", { count: wrapperIdsToMerge.length }); - } - - return { - wrappers, - categories, - wrapperIdsToMerge, - }; -}; diff --git a/packages/old-import/src/import-error.ts b/packages/old-import/src/import-error.ts deleted file mode 100644 index 15efdecfb..000000000 --- a/packages/old-import/src/import-error.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { OldmarrConfig } from "@homarr/old-schema"; - -export class OldHomarrImportError extends Error { - constructor(oldConfig: OldmarrConfig, cause: unknown) { - super(`Failed to import old homarr configuration name=${oldConfig.configProperties.name}`, { - cause, - }); - } -} diff --git a/packages/old-import/src/import/collections/board-collection.ts b/packages/old-import/src/import/collections/board-collection.ts deleted file mode 100644 index b7b116ac6..000000000 --- a/packages/old-import/src/import/collections/board-collection.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { createId } from "@homarr/common"; -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { createDbInsertCollectionForTransaction } from "@homarr/db/collection"; -import { normalizeBoardLayoutRoles } from "@homarr/definitions"; -import type { BoardSize, OldmarrConfig } from "@homarr/old-schema"; -import { boardSizes, getBoardSizeName } from "@homarr/old-schema"; - -import { fixSectionIssues } from "../../fix-section-issues"; -import { OldHomarrImportError } from "../../import-error"; -import { mapBoard } from "../../mappers/map-board"; -import { mapBreakpoint } from "../../mappers/map-breakpoint"; -import { mapColumnCount } from "../../mappers/map-column-count"; -import { moveWidgetsAndAppsIfMerge } from "../../move-widgets-and-apps-merge"; -import { prepareItems } from "../../prepare/prepare-items"; -import type { prepareMultipleImports } from "../../prepare/prepare-multiple"; -import { placePreparedSections, prepareSections } from "../../prepare/prepare-sections"; -import type { InitialOldmarrImportSettings } from "../../settings"; - -const logger = createLogger({ module: "boardCollection" }); - -export const createBoardInsertCollection = ( - { preparedApps, preparedBoards }: Omit, "preparedIntegrations">, - settings: InitialOldmarrImportSettings, -) => { - const insertCollection = createDbInsertCollectionForTransaction([ - "apps", - "boards", - "layouts", - "sections", - "sectionLayouts", - "items", - "itemLayouts", - ]); - logger.info("Preparing boards for insert collection"); - - const appsMap = new Map( - preparedApps.flatMap(({ ids, ...app }) => { - const id = app.existingId ?? createId(); - return ids.map((oldId) => [oldId, { id, ...app }] as const); - }), - ); - - for (const app of appsMap.values()) { - // Skip duplicate apps - if (insertCollection.apps.some((appEntry) => appEntry.id === app.id)) { - continue; - } - // Skip apps that already exist in the database - if (app.existingId) { - continue; - } - - insertCollection.apps.push(app); - } - - if (settings.onlyImportApps) { - logger.info("Skipping boards and sections import due to onlyImportApps setting", { - appCount: insertCollection.apps.length, - }); - return insertCollection; - } - logger.debug("Added apps to board insert collection", { count: insertCollection.apps.length }); - - preparedBoards.forEach((board) => { - if (!hasEnoughItemShapes(board.config)) { - throw new OldHomarrImportError( - board.config, - new Error("Your config contains items without shapes for all board sizes."), - ); - } - - const { wrappers, categories, wrapperIdsToMerge } = fixSectionIssues(board.config); - const { apps, widgets } = moveWidgetsAndAppsIfMerge(board.config, wrapperIdsToMerge, { - ...settings, - name: board.name, - }); - - logger.debug("Fixed issues with sections and item positions", { fileName: board.name }); - - const mappedBoard = mapBoard(board); - logger.debug("Mapped board", { fileName: board.name, boardId: mappedBoard.id }); - insertCollection.boards.push(mappedBoard); - - const layoutMapping = boardSizes.reduce( - (acc, size) => { - acc[size] = createId(); - return acc; - }, - {} as Record, - ); - - const importedLayouts = normalizeBoardLayoutRoles( - boardSizes.map((size) => ({ - id: layoutMapping[size], - boardId: mappedBoard.id, - columnCount: mapColumnCount(board.config.settings.customization.gridstack, size), - leftGutterColumnCount: 0, - rightGutterColumnCount: 0, - breakpoint: mapBreakpoint(size), - name: getBoardSizeName(size), - })), - ); - - insertCollection.layouts.push(...importedLayouts); - - const preparedSections = prepareSections(mappedBoard.id, { wrappers, categories }); - - insertCollection.sections.push(...preparedSections.sections); - logger.debug("Added sections to board insert collection", { count: insertCollection.sections.length }); - - const preparedItems = prepareItems( - { - apps, - widgets, - settings: board.config.settings, - }, - appsMap, - preparedSections.byLegacyId, - layoutMapping, - mappedBoard.id, - ); - insertCollection.sectionLayouts.push(...placePreparedSections(preparedSections, preparedItems, importedLayouts)); - preparedItems.forEach(({ layouts, ...item }) => { - insertCollection.items.push(item); - insertCollection.itemLayouts.push(...layouts); - }); - logger.debug("Added items to board insert collection", { count: insertCollection.items.length }); - }); - - logger.info("Board collection prepared", { - boardCount: insertCollection.boards.length, - sectionCount: insertCollection.sections.length, - itemCount: insertCollection.items.length, - appCount: insertCollection.apps.length, - }); - - return insertCollection; -}; - -export const hasEnoughItemShapes = (config: { - apps: Pick[]; - widgets: Pick[]; -}) => { - const invalidSizes: BoardSize[] = []; - - for (const size of boardSizes) { - if (invalidSizes.includes(size)) continue; - - if (config.apps.some((app) => app.shape[size] === undefined)) { - invalidSizes.push(size); - } - - if (invalidSizes.includes(size)) continue; - - if (config.widgets.some((widget) => widget.shape[size] === undefined)) { - invalidSizes.push(size); - } - } - - return invalidSizes.length <= 2; -}; diff --git a/packages/old-import/src/import/collections/integration-collection.ts b/packages/old-import/src/import/collections/integration-collection.ts deleted file mode 100644 index 33b182040..000000000 --- a/packages/old-import/src/import/collections/integration-collection.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { encryptSecret } from "@homarr/common/server"; -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { createDbInsertCollectionForTransaction } from "@homarr/db/collection"; - -import { mapAndDecryptIntegrations } from "../../mappers/map-integration"; -import type { PreparedIntegration } from "../../prepare/prepare-integrations"; - -const logger = createLogger({ module: "integrationCollection" }); - -export const createIntegrationInsertCollection = ( - preparedIntegrations: PreparedIntegration[], - encryptionToken: string | null | undefined, -) => { - const insertCollection = createDbInsertCollectionForTransaction(["integrations", "integrationSecrets"]); - - if (preparedIntegrations.length === 0) { - return insertCollection; - } - - logger.info("Preparing integrations for insert collection", { count: preparedIntegrations.length }); - - if (encryptionToken === null || encryptionToken === undefined) { - logger.debug("Skipping integration decryption due to missing token"); - return insertCollection; - } - - const preparedIntegrationsDecrypted = mapAndDecryptIntegrations(preparedIntegrations, encryptionToken); - - preparedIntegrationsDecrypted.forEach((integration) => { - insertCollection.integrations.push({ - id: integration.id, - kind: integration.kind, - name: integration.name, - url: integration.url, - }); - - integration.secrets - .filter((secret) => secret.value !== null) - .forEach((secret) => { - insertCollection.integrationSecrets.push({ - integrationId: integration.id, - kind: secret.field, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - value: encryptSecret(secret.value!), - }); - }); - }); - - logger.info("Added integrations and secrets to insert collection", { - integrationCount: insertCollection.integrations.length, - secretCount: insertCollection.integrationSecrets.length, - }); - - return insertCollection; -}; diff --git a/packages/old-import/src/import/collections/user-collection.ts b/packages/old-import/src/import/collections/user-collection.ts deleted file mode 100644 index 3fb9cc14b..000000000 --- a/packages/old-import/src/import/collections/user-collection.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { createId } from "@homarr/common"; -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { createDbInsertCollectionForTransaction } from "@homarr/db/collection"; -import { credentialsAdminGroup } from "@homarr/definitions"; - -import { mapAndDecryptUsers } from "../../mappers/map-user"; -import type { OldmarrImportUser } from "../../user-schema"; - -const logger = createLogger({ module: "userCollection" }); - -export const createUserInsertCollection = ( - importUsers: OldmarrImportUser[], - encryptionToken: string | null | undefined, -) => { - const insertCollection = createDbInsertCollectionForTransaction([ - "users", - "groups", - "groupMembers", - "groupPermissions", - ]); - - if (importUsers.length === 0) { - return insertCollection; - } - - logger.info("Preparing users for insert collection", { count: importUsers.length }); - - if (encryptionToken === null || encryptionToken === undefined) { - logger.debug("Skipping user decryption due to missing token"); - return insertCollection; - } - - const preparedUsers = mapAndDecryptUsers(importUsers, encryptionToken); - preparedUsers.forEach((user) => insertCollection.users.push(user)); - logger.debug("Added users to insert collection", { count: insertCollection.users.length }); - - if (!preparedUsers.some((user) => user.isAdmin)) { - logger.warn("No admin users found, skipping admin group creation"); - return insertCollection; - } - - const adminGroupId = createId(); - insertCollection.groups.push({ - id: adminGroupId, - name: credentialsAdminGroup, - position: 1, - }); - - insertCollection.groupPermissions.push({ - groupId: adminGroupId, - permission: "admin", - }); - - const admins = preparedUsers.filter((user) => user.isAdmin); - - admins.forEach((user) => { - insertCollection.groupMembers.push({ - groupId: adminGroupId, - userId: user.id, - }); - }); - - logger.info("Added admin group and permissions to insert collection", { - adminGroupId, - adminUsersCount: admins.length, - }); - - return insertCollection; -}; diff --git a/packages/old-import/src/import/import-initial-oldmarr.ts b/packages/old-import/src/import/import-initial-oldmarr.ts deleted file mode 100644 index f773c1b36..000000000 --- a/packages/old-import/src/import/import-initial-oldmarr.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { z } from "zod/v4"; - -import { Stopwatch } from "@homarr/common"; -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { handleTransactionsAsync } from "@homarr/db"; -import type { Database } from "@homarr/db"; - -import { analyseOldmarrImportAsync } from "../analyse/analyse-oldmarr-import"; -import { prepareMultipleImports } from "../prepare/prepare-multiple"; -import { createBoardInsertCollection } from "./collections/board-collection"; -import { createIntegrationInsertCollection } from "./collections/integration-collection"; -import { createUserInsertCollection } from "./collections/user-collection"; -import type { importInitialOldmarrInputSchema } from "./input"; -import { ensureValidTokenOrThrow } from "./validate-token"; - -const logger = createLogger({ module: "importInitialOldmarr" }); - -export const importInitialOldmarrAsync = async ( - db: Database, - input: z.infer, -) => { - const stopwatch = new Stopwatch(); - const { checksum, configs, users: importUsers } = await analyseOldmarrImportAsync(input.file); - ensureValidTokenOrThrow(checksum, input.token); - - const { preparedApps, preparedBoards, preparedIntegrations } = prepareMultipleImports( - configs, - input.settings, - input.boardSelections, - ); - - logger.info("Preparing import data in insert collections for database"); - - const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, input.settings); - const userInsertCollection = createUserInsertCollection(importUsers, input.token); - const integrationInsertCollection = createIntegrationInsertCollection(preparedIntegrations, input.token); - - logger.info("Inserting import data to database"); - - await handleTransactionsAsync(db, { - async handleAsync(db) { - await db.transaction(async (transaction) => { - await boardInsertCollection.insertAllAsync(transaction); - await userInsertCollection.insertAllAsync(transaction); - await integrationInsertCollection.insertAllAsync(transaction); - }); - }, - handleSync(db) { - db.transaction((transaction) => { - boardInsertCollection.insertAll(transaction); - userInsertCollection.insertAll(transaction); - integrationInsertCollection.insertAll(transaction); - }); - }, - }); - - logger.info("Import successful", { duration: stopwatch.getElapsedInHumanWords() }); -}; diff --git a/packages/old-import/src/import/import-single-oldmarr.ts b/packages/old-import/src/import/import-single-oldmarr.ts deleted file mode 100644 index 2ca6ed4c1..000000000 --- a/packages/old-import/src/import/import-single-oldmarr.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { handleTransactionsAsync, inArray } from "@homarr/db"; -import type { Database } from "@homarr/db"; -import { apps } from "@homarr/db/schema"; -import type { OldmarrConfig } from "@homarr/old-schema"; - -import { doAppsMatch } from "../prepare/prepare-apps"; -import { prepareSingleImport } from "../prepare/prepare-single"; -import type { OldmarrImportConfiguration } from "../settings"; -import { createBoardInsertCollection } from "./collections/board-collection"; - -export const importSingleOldmarrConfigAsync = async ( - db: Database, - config: OldmarrConfig, - settings: OldmarrImportConfiguration, -) => { - const { preparedApps, preparedBoards } = prepareSingleImport(config, settings); - const existingApps = await db.query.apps.findMany({ - where: inArray( - apps.href, - preparedApps.map((app) => app.href).filter((href) => href !== null), - ), - }); - - preparedApps.forEach((app) => { - const existingApp = existingApps.find((existingApp) => doAppsMatch(existingApp, app)); - if (existingApp) { - app.existingId = existingApp.id; - } - return app; - }); - - const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, settings); - - await handleTransactionsAsync(db, { - async handleAsync(db) { - await boardInsertCollection.insertAllAsync(db); - }, - handleSync(db) { - boardInsertCollection.insertAll(db); - }, - }); -}; diff --git a/packages/old-import/src/import/index.ts b/packages/old-import/src/import/index.ts deleted file mode 100644 index 1c2c4cc72..000000000 --- a/packages/old-import/src/import/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { importInitialOldmarrAsync } from "./import-initial-oldmarr"; -export * from "./input"; -export { ensureValidTokenOrThrow } from "./validate-token"; diff --git a/packages/old-import/src/import/input.ts b/packages/old-import/src/import/input.ts deleted file mode 100644 index 681227dbb..000000000 --- a/packages/old-import/src/import/input.ts +++ /dev/null @@ -1,17 +0,0 @@ -import SuperJSON from "superjson"; -import { zfd } from "zod-form-data"; -import { z } from "zod/v4"; - -import { initialOldmarrImportSettings } from "../settings"; - -const boardSelectionMapSchema = z.map(z.string(), z.boolean()); - -export const importInitialOldmarrInputSchema = zfd.formData({ - file: zfd.file(), - settings: zfd.json(initialOldmarrImportSettings), - boardSelections: zfd.text().transform((value) => { - const map = boardSelectionMapSchema.parse(SuperJSON.parse(value)); - return map; - }), - token: zfd.text().nullable().optional(), -}); diff --git a/packages/old-import/src/import/test/board-collection.spec.ts b/packages/old-import/src/import/test/board-collection.spec.ts deleted file mode 100644 index 0933b0de7..000000000 --- a/packages/old-import/src/import/test/board-collection.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import type { BoardSize } from "@homarr/old-schema"; - -import { hasEnoughItemShapes } from "../collections/board-collection"; - -const defaultShape = { - location: { - x: 0, - y: 0, - }, - size: { - width: 1, - height: 1, - }, -}; - -describe("hasEnoughItemShapes should check if there are more than one shape available for automatic reconstruction", () => { - test.each([ - [true, [], []], // no items, so nothing to check - [true, [{ lg: true }], []], // lg always exists - [true, [], [{ md: true }]], // md always exists - [true, [{ md: true, sm: true }], [{ md: true, lg: true }]], // md always exists - [true, [{ md: true }], [{ md: true }]], // md always exists - [false, [{ md: true }, { md: true }], [{ lg: true }]], // md is missing for widgets - [false, [{ md: true }], [{ lg: true }]], // md is missing for widgets - [false, [{ md: true }], [{ md: true, lg: true }, { lg: true }]], // md is missing for 2. widget - ] as [boolean, Shape[], Shape[]][])( - "should return %s if there are more than one shape available", - (returnValue, appShapes, widgetShapes) => { - const result = hasEnoughItemShapes({ - apps: appShapes.map((shapes) => ({ - shape: { - sm: shapes.sm ? defaultShape : undefined, - md: shapes.md ? defaultShape : undefined, - lg: shapes.lg ? defaultShape : undefined, - }, - })), - widgets: widgetShapes.map((shapes) => ({ - shape: { - sm: shapes.sm ? defaultShape : undefined, - md: shapes.md ? defaultShape : undefined, - lg: shapes.lg ? defaultShape : undefined, - }, - })), - }); - - expect(result).toBe(returnValue); - }, - ); -}); - -type Shape = Partial>; diff --git a/packages/old-import/src/import/validate-token.ts b/packages/old-import/src/import/validate-token.ts deleted file mode 100644 index 74e43e52e..000000000 --- a/packages/old-import/src/import/validate-token.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { decryptSecretWithKey } from "@homarr/common/server"; - -export const ensureValidTokenOrThrow = (checksum: string | undefined, encryptionToken: string | null | undefined) => { - if (!encryptionToken || !checksum) return; - - const [first, second] = checksum.split("\n"); - if (!first || !second) throw new Error("Malformed checksum"); - - const key = Buffer.from(encryptionToken, "hex"); - let decrypted: string; - try { - decrypted = decryptSecretWithKey(second as `${string}.${string}`, key); - } catch { - throw new Error("Invalid checksum"); - } - const isValid = decrypted === first; - if (!isValid) throw new Error("Invalid checksum"); -}; diff --git a/packages/old-import/src/index.ts b/packages/old-import/src/index.ts deleted file mode 100644 index d3153a189..000000000 --- a/packages/old-import/src/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Database } from "@homarr/db"; -import type { OldmarrConfig } from "@homarr/old-schema"; - -import { importSingleOldmarrConfigAsync } from "./import/import-single-oldmarr"; -import type { OldmarrImportConfiguration } from "./settings"; - -export const importOldmarrAsync = async ( - db: Database, - old: OldmarrConfig, - configuration: OldmarrImportConfiguration, -) => { - await importSingleOldmarrConfigAsync(db, old, configuration); -}; diff --git a/packages/old-import/src/mappers/map-app.ts b/packages/old-import/src/mappers/map-app.ts deleted file mode 100644 index cb4e6037d..000000000 --- a/packages/old-import/src/mappers/map-app.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { InferSelectModel } from "@homarr/db"; -import type { apps } from "@homarr/db/schema"; -import type { OldmarrApp } from "@homarr/old-schema"; - -import type { OldmarrBookmarkDefinition } from "../widgets/definitions/bookmark"; - -export const mapOldmarrApp = (app: OldmarrApp): InferSelectModel => { - return { - id: app.id, - name: app.name, - iconUrl: app.appearance.iconUrl, - description: app.behaviour.tooltipDescription ?? null, - href: app.behaviour.externalUrl || app.url, - pingUrl: app.url.length > 0 ? app.url : null, - }; -}; - -export const mapOldmarrBookmarkApp = ( - app: OldmarrBookmarkDefinition["options"]["items"][number], -): InferSelectModel => { - return { - id: app.id, - name: app.name, - iconUrl: app.iconUrl, - description: null, - href: app.href, - pingUrl: null, - }; -}; diff --git a/packages/old-import/src/mappers/map-board.ts b/packages/old-import/src/mappers/map-board.ts deleted file mode 100644 index e9ce89921..000000000 --- a/packages/old-import/src/mappers/map-board.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createId } from "@homarr/common"; -import type { InferInsertModel } from "@homarr/db"; -import type { boards } from "@homarr/db/schema"; - -import type { prepareMultipleImports } from "../prepare/prepare-multiple"; -import { mapColor } from "./map-colors"; - -type PreparedBoard = ReturnType["preparedBoards"][number]; - -export const mapBoard = (preparedBoard: PreparedBoard): InferInsertModel => ({ - id: createId(), - name: preparedBoard.name, - backgroundImageAttachment: preparedBoard.config.settings.customization.backgroundImageAttachment, - backgroundImageUrl: preparedBoard.config.settings.customization.backgroundImageUrl, - backgroundImageRepeat: preparedBoard.config.settings.customization.backgroundImageRepeat, - backgroundImageSize: preparedBoard.config.settings.customization.backgroundImageSize, - faviconImageUrl: mapFavicon(preparedBoard.config.settings.customization.faviconUrl), - isPublic: preparedBoard.config.settings.access.allowGuests, - logoImageUrl: mapLogo(preparedBoard.config.settings.customization.logoImageUrl), - pageTitle: preparedBoard.config.settings.customization.pageTitle, - metaTitle: preparedBoard.config.settings.customization.metaTitle, - opacity: preparedBoard.config.settings.customization.appOpacity, - primaryColor: mapColor(preparedBoard.config.settings.customization.colors.primary, "#fa5252"), - secondaryColor: mapColor(preparedBoard.config.settings.customization.colors.secondary, "#fd7e14"), -}); - -const defaultOldmarrLogoPath = "/imgs/logo/logo.png"; - -const mapLogo = (logo: string | null | undefined) => { - if (!logo) { - return null; - } - - if (logo.trim() === defaultOldmarrLogoPath) { - return null; // We fallback to default logo when null - } - - return logo; -}; - -const defaultOldmarrFaviconPath = "/imgs/favicon/favicon-squared.png"; - -const mapFavicon = (favicon: string | null | undefined) => { - if (!favicon) { - return null; - } - - if (favicon.trim() === defaultOldmarrFaviconPath) { - return null; // We fallback to default favicon when null - } - - return favicon; -}; diff --git a/packages/old-import/src/mappers/map-breakpoint.ts b/packages/old-import/src/mappers/map-breakpoint.ts deleted file mode 100644 index 67e74be5b..000000000 --- a/packages/old-import/src/mappers/map-breakpoint.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { BoardSize } from "@homarr/old-schema"; - -/** - * Copied from https://github.com/ajnart/homarr/blob/274eaa92084a8be4d04a69a87f9920860a229128/src/components/Dashboard/Wrappers/gridstack/store.tsx#L21-L30 - * @param screenSize board size - * @returns layout breakpoint for the board - */ -export const mapBreakpoint = (screenSize: BoardSize) => { - switch (screenSize) { - case "lg": - return 1400; - case "md": - return 800; - case "sm": - default: - return 0; - } -}; diff --git a/packages/old-import/src/mappers/map-colors.ts b/packages/old-import/src/mappers/map-colors.ts deleted file mode 100644 index 73318514c..000000000 --- a/packages/old-import/src/mappers/map-colors.ts +++ /dev/null @@ -1,48 +0,0 @@ -const oldColors = [ - "dark", - "gray", - "red", - "pink", - "grape", - "violet", - "indigo", - "blue", - "cyan", - "green", - "lime", - "yellow", - "orange", - "teal", -] as const; -type OldColor = (typeof oldColors)[number]; - -export const mapColor = (color: string | undefined, fallback: string) => { - if (!color) { - return fallback; - } - - if (!oldColors.some((mantineColor) => color === mantineColor)) { - return fallback; - } - - const mantineColor = color as OldColor; - - return mappedColors[mantineColor]; -}; - -const mappedColors: Record<(typeof oldColors)[number], string> = { - blue: "#228be6", - cyan: "#15aabf", - dark: "#2e2e2e", - grape: "#be4bdb", - gray: "#868e96", - green: "#40c057", - indigo: "#4c6ef5", - lime: "#82c91e", - orange: "#fd7e14", - pink: "#e64980", - red: "#fa5252", - teal: "#12b886", - violet: "#7950f2", - yellow: "#fab005", -}; diff --git a/packages/old-import/src/mappers/map-column-count.ts b/packages/old-import/src/mappers/map-column-count.ts deleted file mode 100644 index 1cf457573..000000000 --- a/packages/old-import/src/mappers/map-column-count.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { BoardSize, OldmarrConfig } from "@homarr/old-schema"; - -export const mapColumnCount = ( - gridstackSettings: OldmarrConfig["settings"]["customization"]["gridstack"], - screenSize: BoardSize, -) => { - switch (screenSize) { - case "lg": - return gridstackSettings.columnCountLarge; - case "md": - return gridstackSettings.columnCountMedium; - case "sm": - return gridstackSettings.columnCountSmall; - default: - return 10; - } -}; diff --git a/packages/old-import/src/mappers/map-integration.ts b/packages/old-import/src/mappers/map-integration.ts deleted file mode 100644 index a9124e256..000000000 --- a/packages/old-import/src/mappers/map-integration.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { createId } from "@homarr/common"; -import { decryptSecretWithKey } from "@homarr/common/server"; -import type { IntegrationKind } from "@homarr/definitions"; -import type { OldmarrIntegrationType } from "@homarr/old-schema"; - -import type { PreparedIntegration } from "../prepare/prepare-integrations"; - -export const mapIntegrationType = (type: OldmarrIntegrationType) => { - const kind = mapping[type]; - if (!kind) { - throw new Error(`Integration type ${type} is not supported yet`); - } - return kind; -}; - -const mapping: Record = { - adGuardHome: "adGuardHome", - deluge: "deluge", - homeAssistant: "homeAssistant", - jellyfin: "jellyfin", - jellyseerr: "jellyseerr", - lidarr: "lidarr", - nzbGet: "nzbGet", - openmediavault: "openmediavault", - overseerr: "overseerr", - pihole: "piHole", - prowlarr: "prowlarr", - proxmox: "proxmox", - qBittorrent: "qBittorrent", - radarr: "radarr", - readarr: "readarr", - sabnzbd: "sabNzbd", - sonarr: "sonarr", - tdarr: "tdarr", - transmission: "transmission", - plex: "plex", -}; - -export const mapAndDecryptIntegrations = ( - preparedIntegrations: PreparedIntegration[], - encryptionToken: string | null, -) => { - if (encryptionToken === null) { - return []; - } - - return preparedIntegrations.map(({ type, name, url, properties }) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const kind = mapIntegrationType(type!); - - return { - id: createId(), - name, - url, - kind, - secrets: mapSecrets(properties, encryptionToken, kind), - }; - }); -}; - -const mapSecrets = (properties: PreparedIntegration["properties"], encryptionToken: string, kind: IntegrationKind) => { - const key = Buffer.from(encryptionToken, "hex"); - - const decryptedProperties = properties.map((property) => ({ - ...property, - value: property.value ? decryptSecretWithKey(property.value as `${string}.${string}`, key) : null, - })); - - return kind === "proxmox" ? mapProxmoxSecrets(decryptedProperties) : decryptedProperties; -}; - -/** - * Proxmox secrets have bee split up from format `user@realm!tokenId=secret` to separate fields - */ -const mapProxmoxSecrets = (decryptedProperties: PreparedIntegration["properties"]) => { - const apiToken = decryptedProperties.find((property) => property.field === "apiKey"); - - if (!apiToken?.value) return []; - - let splitValues = apiToken.value.split("@"); - - if (splitValues.length <= 1) return []; - - const [user, ...rest] = splitValues; - - splitValues = rest.join("@").split("!"); - - if (splitValues.length <= 1) return []; - - const [realm, ...rest2] = splitValues; - - splitValues = rest2.join("!").split("="); - - if (splitValues.length <= 1) return []; - - const [tokenId, ...rest3] = splitValues; - - const secret = rest3.join("="); - - return [ - { - field: "username" as const, - value: user, - }, - { - field: "realm" as const, - value: realm, - }, - { - field: "tokenId" as const, - value: tokenId, - }, - { - field: "apiKey" as const, - value: secret, - }, - ]; -}; diff --git a/packages/old-import/src/mappers/map-item.ts b/packages/old-import/src/mappers/map-item.ts deleted file mode 100644 index 2e339a0b2..000000000 --- a/packages/old-import/src/mappers/map-item.ts +++ /dev/null @@ -1,124 +0,0 @@ -import SuperJSON from "superjson"; - -import { createId } from "@homarr/common"; -import { createLogger } from "@homarr/core/infrastructure/logs"; -import type { InferInsertModel } from "@homarr/db"; -import type { itemLayouts, items } from "@homarr/db/schema"; -import type { BoardSize, OldmarrApp, OldmarrWidget } from "@homarr/old-schema"; -import { boardSizes } from "@homarr/old-schema"; - -import type { WidgetComponentProps } from "../../../widgets/src/definition"; -import { mapKind } from "../widgets/definitions"; -import { mapOptions } from "../widgets/options"; - -const logger = createLogger({ module: "mapItem" }); - -export const mapApp = ( - app: OldmarrApp, - appsMap: Map, - sectionMap: Map, - layoutMap: Record, - boardId: string, -): (InferInsertModel & { layouts: InferInsertModel[] }) | null => { - if (app.area.type === "sidebar") throw new Error("Mapping app in sidebar is not supported"); - - const sectionId = sectionMap.get(app.area.properties.id)?.id; - if (!sectionId) { - logger.warn("Failed to find section for app. Removing app", { - appId: app.id, - sectionId: app.area.properties.id, - }); - return null; - } - - const itemId = createId(); - return { - id: itemId, - boardId, - kind: "app", - options: SuperJSON.stringify({ - // it's safe to assume that the app exists in the map - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain - appId: appsMap.get(app.id)?.id!, - openInNewTab: app.behaviour.isOpeningNewTab, - pingEnabled: app.network.enabledStatusChecker, - showTitle: app.appearance.appNameStatus === "normal", - layout: app.appearance.positionAppName, - descriptionDisplayMode: app.behaviour.tooltipDescription !== "" ? "tooltip" : "hidden", - } satisfies WidgetComponentProps<"app">["options"]), - layouts: boardSizes.map((size) => { - const shapeForSize = app.shape[size]; - if (!shapeForSize) { - throw new Error(`Failed to find a shape for appId='${app.id}' screenSize='${size}'`); - } - - return { - itemId, - height: shapeForSize.size.height, - width: shapeForSize.size.width, - xOffset: shapeForSize.location.x, - yOffset: shapeForSize.location.y, - sectionId, - layoutId: layoutMap[size], - }; - }), - }; -}; - -export const mapWidget = ( - widget: OldmarrWidget, - appsMap: Map, - sectionMap: Map, - layoutMap: Record, - boardId: string, -): (InferInsertModel & { layouts: InferInsertModel[] }) | null => { - if (widget.area.type === "sidebar") throw new Error("Mapping widget in sidebar is not supported"); - - const kind = mapKind(widget.type); - if (!kind) { - logger.warn("Failed to map widget type. It's no longer supported", { - widgetId: widget.id, - widgetType: widget.type, - }); - return null; - } - - const sectionId = sectionMap.get(widget.area.properties.id)?.id; - if (!sectionId) { - logger.warn("Failed to find section for widget. Removing widget", { - widgetId: widget.id, - sectionId: widget.area.properties.id, - }); - return null; - } - - const itemId = createId(); - return { - id: itemId, - boardId, - kind, - options: SuperJSON.stringify( - mapOptions( - widget.type, - widget.properties, - new Map([...appsMap.entries()].map(([key, value]) => [key, value.id])), - ), - ), - layouts: boardSizes.map((size) => { - const shapeForSize = widget.shape[size]; - if (!shapeForSize) { - throw new Error(`Failed to find a shape for widgetId='${widget.id}' screenSize='${size}'`); - } - - return { - itemId, - height: shapeForSize.size.height, - width: shapeForSize.size.width, - xOffset: shapeForSize.location.x, - yOffset: shapeForSize.location.y, - sectionId, - layoutId: layoutMap[size], - }; - }), - }; -}; diff --git a/packages/old-import/src/mappers/map-section.ts b/packages/old-import/src/mappers/map-section.ts deleted file mode 100644 index f69b6b4e8..000000000 --- a/packages/old-import/src/mappers/map-section.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { stringify } from "superjson"; - -import { createId } from "@homarr/common"; -import type { InferInsertModel } from "@homarr/db"; -import type { sections } from "@homarr/db/schema"; -import { emptySuperJSON, rootSectionOffsets } from "@homarr/definitions"; -import type { OldmarrCategorySection } from "@homarr/old-schema"; -import { containerSectionOptionsSchema } from "@homarr/validation/shared"; - -export const mapLegacyCategoryContainer = ( - boardId: string, - category: OldmarrCategorySection, -): InferInsertModel => ({ - id: createId(), - boardId, - kind: "container", - xOffset: null, - yOffset: null, - name: null, - options: stringify( - containerSectionOptionsSchema.parse({ - title: category.name.slice(0, 64), - showLabel: true, - collapsible: true, - showOpenAll: true, - }), - ), -}); - -export const mapMainRootSection = (boardId: string): InferInsertModel => ({ - id: createId(), - boardId, - kind: "empty", - xOffset: rootSectionOffsets.main, - yOffset: 0, - name: null, - options: emptySuperJSON, -}); diff --git a/packages/old-import/src/mappers/map-user.ts b/packages/old-import/src/mappers/map-user.ts deleted file mode 100644 index 240961b3d..000000000 --- a/packages/old-import/src/mappers/map-user.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { createId } from "@homarr/common"; -import { decryptSecretWithKey } from "@homarr/common/server"; -import type { InferInsertModel } from "@homarr/db"; -import type { users } from "@homarr/db/schema"; - -import type { OldmarrImportUser } from "../user-schema"; - -export const mapAndDecryptUsers = (importUsers: OldmarrImportUser[], encryptionToken: string | null) => { - if (encryptionToken === null) { - return []; - } - - const key = Buffer.from(encryptionToken, "hex"); - - return importUsers.map( - ({ - id, - password, - salt: _saltIsNotNeededAsPartOfPassword, - settings, - ...user - }): InferInsertModel & { oldId: string; isAdmin: boolean } => ({ - ...user, - oldId: id, - id: createId(), - name: user.name.toLowerCase(), - colorScheme: settings?.colorScheme === "environment" ? "auto" : settings?.colorScheme, - firstDayOfWeek: settings?.firstDayOfWeek === "sunday" ? 0 : settings?.firstDayOfWeek === "monday" ? 1 : 6, - provider: "credentials", - pingIconsEnabled: settings?.replacePingWithIcons, - isAdmin: user.isAdmin || user.isOwner, - password: decryptSecretWithKey(password, key), - }), - ); -}; diff --git a/packages/old-import/src/mappers/test/map-integration.spec.ts b/packages/old-import/src/mappers/test/map-integration.spec.ts deleted file mode 100644 index 976da6126..000000000 --- a/packages/old-import/src/mappers/test/map-integration.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; - -import * as commonServer from "@homarr/common/server"; - -import type { PreparedIntegration } from "../../prepare/prepare-integrations"; -import { mapAndDecryptIntegrations } from "../map-integration"; - -describe("Map Integrations", () => { - test("should map proxmox integration", () => { - vi.spyOn(commonServer, "decryptSecretWithKey").mockReturnValue("user@realm!tokenId=secret"); - - const proxmoxIntegration: PreparedIntegration = { - type: "proxmox", - name: "Proxmox", - url: "https://proxmox.com", - properties: [ - { - field: "apiKey", - value: "any-encrypted-value", - type: "private", - }, - ], - }; - - const mappedIntegrations = mapAndDecryptIntegrations([proxmoxIntegration], "encryptionToken"); - - expect(mappedIntegrations[0]?.secrets).toEqual([ - { - field: "username", - value: "user", - }, - { - field: "realm", - value: "realm", - }, - { - field: "tokenId", - value: "tokenId", - }, - { - field: "apiKey", - value: "secret", - }, - ]); - }); -}); diff --git a/packages/old-import/src/move-widgets-and-apps-merge.ts b/packages/old-import/src/move-widgets-and-apps-merge.ts deleted file mode 100644 index 5cb74a160..000000000 --- a/packages/old-import/src/move-widgets-and-apps-merge.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { objectEntries } from "@homarr/common"; -import { createLogger } from "@homarr/core/infrastructure/logs"; -import type { BoardSize, OldmarrApp, OldmarrConfig, OldmarrWidget } from "@homarr/old-schema"; -import { boardSizes } from "@homarr/old-schema"; - -import { mapColumnCount } from "./mappers/map-column-count"; -import type { OldmarrImportConfiguration } from "./settings"; - -const logger = createLogger({ module: "moveWidgetsAndAppsMerge" }); - -export const moveWidgetsAndAppsIfMerge = ( - old: OldmarrConfig, - wrapperIdsToMerge: string[], - configuration: OldmarrImportConfiguration, -) => { - const firstId = wrapperIdsToMerge[0]; - if (!firstId) { - return { apps: old.apps, widgets: old.widgets }; - } - - const affectedMap = new Map( - wrapperIdsToMerge.map((id) => [ - id, - { - apps: old.apps.filter((app) => app.area.type !== "sidebar" && id === app.area.properties.id), - widgets: old.widgets.filter((app) => app.area.type !== "sidebar" && id === app.area.properties.id), - }, - ]), - ); - - logger.debug("Merging wrappers at the end of the board", { count: wrapperIdsToMerge.length }); - - const offsets = boardSizes.reduce( - (previous, screenSize) => { - previous[screenSize] = 0; - return previous; - }, - {} as Record, - ); - for (const id of wrapperIdsToMerge) { - const requiredHeights = boardSizes.reduce( - (previous, screenSize) => { - previous[screenSize] = 0; - return previous; - }, - {} as Record, - ); - const affected = affectedMap.get(id); - if (!affected) { - continue; - } - - const apps = affected.apps; - const widgets = affected.widgets; - - for (const app of apps) { - if (app.area.type === "sidebar") continue; - // Move item to first wrapper - app.area.properties.id = firstId; - - for (const screenSize of boardSizes) { - const screenSizeShape = app.shape[screenSize]; - if (!screenSizeShape) { - continue; - } - - // Find the highest widget in the wrapper to increase the offset accordingly - if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeights[screenSize]) { - requiredHeights[screenSize] = screenSizeShape.location.y + screenSizeShape.size.height; - } - - // Move item down as much as needed to not overlap with other items - screenSizeShape.location.y += offsets[screenSize]; - } - } - - for (const widget of widgets) { - if (widget.area.type === "sidebar") continue; - // Move item to first wrapper - widget.area.properties.id = firstId; - - for (const screenSize of boardSizes) { - const screenSizeShape = widget.shape[screenSize]; - if (!screenSizeShape) { - continue; - } - - // Find the highest widget in the wrapper to increase the offset accordingly - if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeights[screenSize]) { - requiredHeights[screenSize] = screenSizeShape.location.y + screenSizeShape.size.height; - } - - // Move item down as much as needed to not overlap with other items - screenSizeShape.location.y += offsets[screenSize]; - } - } - - for (const screenSize of boardSizes) { - offsets[screenSize] += requiredHeights[screenSize]; - } - } - - if (configuration.sidebarBehaviour === "last-section") { - const areas = [...old.apps.map((app) => app.area), ...old.widgets.map((widget) => widget.area)]; - if ( - old.settings.customization.layout.enabledLeftSidebar || - areas.some((area) => area.type === "sidebar" && area.properties.location === "left") - ) { - for (const screenSize of boardSizes) { - offsets[screenSize] = moveWidgetsAndAppsInLeftSidebar(old, firstId, offsets[screenSize], screenSize); - } - } - - if ( - old.settings.customization.layout.enabledRightSidebar || - areas.some((area) => area.type === "sidebar" && area.properties.location === "right") - ) { - for (const screenSize of boardSizes) { - moveWidgetsAndAppsInRightSidebar(old, firstId, offsets[screenSize], screenSize); - } - } - } else { - // Remove all widgets and apps in the sidebar - return { - apps: old.apps.filter((app) => app.area.type !== "sidebar"), - widgets: old.widgets.filter((app) => app.area.type !== "sidebar"), - }; - } - - return { apps: old.apps, widgets: old.widgets }; -}; - -const moveWidgetsAndAppsInLeftSidebar = ( - old: OldmarrConfig, - firstId: string, - offset: number, - screenSize: BoardSize, -) => { - const columnCount = mapColumnCount(old.settings.customization.gridstack, screenSize); - let requiredHeight = updateItems({ - // This should work as the reference of the items did not change, only the array reference did - items: [...old.widgets, ...old.apps], - screenSize, - filter: (item) => - item.area.type === "sidebar" && - item.area.properties.location === "left" && - (columnCount >= 2 || item.shape[screenSize]?.location.x === 0), - update: (item) => { - item.area = { - type: "wrapper", - properties: { - id: firstId, - }, - }; - - const screenSizeShape = item.shape[screenSize]; - if (!screenSizeShape) return; - - // Reduce width to one if column count is one - if (screenSizeShape.size.width > columnCount) { - screenSizeShape.size.width = columnCount; - } - - screenSizeShape.location.y += offset; - }, - }); - - // Only increase offset if there are less than 3 columns because then the items have to be stacked - if (columnCount <= 3) { - offset += requiredHeight; - } - - // When column count is 0 we need to stack the items of the sidebar on top of each other - if (columnCount !== 1) { - return offset; - } - - requiredHeight = updateItems({ - // This should work as the reference of the items did not change, only the array reference did - items: [...old.widgets, ...old.apps], - screenSize, - filter: (item) => - item.area.type === "sidebar" && - item.area.properties.location === "left" && - item.shape[screenSize]?.location.x === 1, - update: (item) => { - item.area = { - type: "wrapper", - properties: { - id: firstId, - }, - }; - - const screenSizeShape = item.shape[screenSize]; - if (!screenSizeShape) return; - - screenSizeShape.location.x = 0; - screenSizeShape.location.y += offset; - }, - }); - - offset += requiredHeight; - return offset; -}; - -const moveWidgetsAndAppsInRightSidebar = ( - old: OldmarrConfig, - firstId: string, - offset: number, - screenSize: BoardSize, -) => { - const columnCount = mapColumnCount(old.settings.customization.gridstack, screenSize); - const xOffsetDelta = Math.max(columnCount - 2, 0); - const requiredHeight = updateItems({ - // This should work as the reference of the items did not change, only the array reference did - items: [...old.widgets, ...old.apps], - screenSize, - filter: (item) => - item.area.type === "sidebar" && - item.area.properties.location === "right" && - (columnCount >= 2 || item.shape[screenSize]?.location.x === 0), - update: (item) => { - item.area = { - type: "wrapper", - properties: { - id: firstId, - }, - }; - - const screenSizeShape = item.shape[screenSize]; - if (!screenSizeShape) return; - - // Reduce width to one if column count is one - if (screenSizeShape.size.width > columnCount) { - screenSizeShape.size.width = columnCount; - } - - screenSizeShape.location.y += offset; - screenSizeShape.location.x += xOffsetDelta; - }, - }); - - // When column count is 0 we need to stack the items of the sidebar on top of each other - if (columnCount !== 1) { - return; - } - - offset += requiredHeight; - - updateItems({ - // This should work as the reference of the items did not change, only the array reference did - items: [...old.widgets, ...old.apps], - screenSize, - filter: (item) => - item.area.type === "sidebar" && - item.area.properties.location === "left" && - item.shape[screenSize]?.location.x === 1, - update: (item) => { - item.area = { - type: "wrapper", - properties: { - id: firstId, - }, - }; - - const screenSizeShape = item.shape[screenSize]; - if (!screenSizeShape) return; - - screenSizeShape.location.x = 0; - screenSizeShape.location.y += offset; - }, - }); -}; - -const createItemSnapshot = (item: OldmarrApp | OldmarrWidget, screenSize: BoardSize) => ({ - x: item.shape[screenSize]?.location.x, - y: item.shape[screenSize]?.location.y, - height: item.shape[screenSize]?.size.height, - width: item.shape[screenSize]?.size.width, - section: - item.area.type === "sidebar" - ? { - type: "sidebar", - location: item.area.properties.location, - } - : { - type: item.area.type, - id: item.area.properties.id, - }, - toString(): string { - return objectEntries(this) - .filter(([key]) => key !== "toString") - .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) - .join(" "); - }, -}); - -const updateItems = (options: { - items: (OldmarrApp | OldmarrWidget)[]; - filter: (item: OldmarrApp | OldmarrWidget) => boolean; - update: (item: OldmarrApp | OldmarrWidget) => void; - screenSize: BoardSize; -}) => { - const items = options.items.filter(options.filter); - let requiredHeight = 0; - for (const item of items) { - const before = createItemSnapshot(item, options.screenSize); - - options.update(item); - - const screenSizeShape = item.shape[options.screenSize]; - if (!screenSizeShape) return requiredHeight; - - if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeight) { - requiredHeight = screenSizeShape.location.y + screenSizeShape.size.height; - } - - const after = createItemSnapshot(item, options.screenSize); - - logger.debug( - `Moved item ${item.id}\n [snapshot before]: ${before.toString()}\n [snapshot after]: ${after.toString()}`, - ); - } - - return requiredHeight; -}; diff --git a/packages/old-import/src/prepare/prepare-apps.ts b/packages/old-import/src/prepare/prepare-apps.ts deleted file mode 100644 index 973ea3653..000000000 --- a/packages/old-import/src/prepare/prepare-apps.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { InferSelectModel } from "@homarr/db"; -import type { apps } from "@homarr/db/schema"; -import type { OldmarrConfig } from "@homarr/old-schema"; - -import type { ValidAnalyseConfig } from "../analyse/types"; -import { mapOldmarrApp, mapOldmarrBookmarkApp } from "../mappers/map-app"; -import type { OldmarrBookmarkDefinition } from "../widgets/definitions/bookmark"; - -export type PreparedApp = Omit, "id"> & { ids: string[]; existingId?: string }; - -export const prepareApps = (analyseConfigs: ValidAnalyseConfig[]) => { - const preparedApps: PreparedApp[] = []; - - analyseConfigs.forEach(({ config }) => { - const appsFromConfig = extractAppsFromConfig(config).concat(extractBookmarkAppsFromConfig(config)); - addAppsToPreparedApps(preparedApps, appsFromConfig); - }); - - return preparedApps; -}; - -const extractAppsFromConfig = (config: OldmarrConfig) => { - return config.apps.map(mapOldmarrApp); -}; - -const extractBookmarkAppsFromConfig = (config: OldmarrConfig) => { - const bookmarkWidgets = config.widgets.filter((widget) => widget.type === "bookmark"); - return bookmarkWidgets.flatMap((widget) => - (widget.properties as OldmarrBookmarkDefinition["options"]).items.map(mapOldmarrBookmarkApp), - ); -}; - -const addAppsToPreparedApps = (preparedApps: PreparedApp[], configApps: InferSelectModel[]) => { - configApps.forEach(({ id, ...app }) => { - const existingApp = preparedApps.find((preparedApp) => doAppsMatch(preparedApp, app)); - - if (existingApp) { - existingApp.ids.push(id); - return; - } - - preparedApps.push({ - ...app, - ids: [id], - }); - }); -}; - -export const doAppsMatch = ( - app1: Omit, "id">, - app2: Omit, "id">, -) => { - return ( - app1.name === app2.name && - app1.iconUrl === app2.iconUrl && - app1.description === app2.description && - app1.href === app2.href - ); -}; diff --git a/packages/old-import/src/prepare/prepare-boards.ts b/packages/old-import/src/prepare/prepare-boards.ts deleted file mode 100644 index e6bcbe1db..000000000 --- a/packages/old-import/src/prepare/prepare-boards.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ValidAnalyseConfig } from "../analyse/types"; -import type { BoardSelectionMap } from "../components/initial/board-selection-card"; - -export const prepareBoards = (analyseConfigs: ValidAnalyseConfig[], selections: BoardSelectionMap) => { - return analyseConfigs.filter(({ name }) => selections.get(name)); -}; diff --git a/packages/old-import/src/prepare/prepare-integrations.ts b/packages/old-import/src/prepare/prepare-integrations.ts deleted file mode 100644 index 6d55ae3d9..000000000 --- a/packages/old-import/src/prepare/prepare-integrations.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ValidAnalyseConfig } from "../analyse/types"; - -export type PreparedIntegration = ReturnType[number]; - -export const prepareIntegrations = (analyseConfigs: ValidAnalyseConfig[]) => { - return analyseConfigs.flatMap(({ config }) => { - return config.apps - .map((app) => - app.integration?.type - ? { - ...app.integration, - name: app.name, - url: app.url, - } - : null, - ) - .filter((integration) => integration !== null); - }); -}; diff --git a/packages/old-import/src/prepare/prepare-items.ts b/packages/old-import/src/prepare/prepare-items.ts deleted file mode 100644 index 61e2b5793..000000000 --- a/packages/old-import/src/prepare/prepare-items.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { generateResponsiveGridFor } from "@homarr/common"; -import type { GridAlgorithmItem } from "@homarr/common"; -import { createLogger } from "@homarr/core/infrastructure/logs"; -import type { BoardSize, OldmarrApp, OldmarrConfig, OldmarrWidget, SizedShape } from "@homarr/old-schema"; -import { boardSizes } from "@homarr/old-schema"; - -import { mapColumnCount } from "../mappers/map-column-count"; -import { mapApp, mapWidget } from "../mappers/map-item"; - -const logger = createLogger({ module: "prepareItems" }); - -export const prepareItems = ( - { apps, widgets, settings }: Pick, - appsMap: Map, - sectionMap: Map, - layoutMap: Record, - boardId: string, -) => { - let localApps = apps; - let localWidgets = widgets; - - const incompleteSizes = boardSizes.filter((size) => - widgets - .map((widget) => widget.shape) - .concat(apps.map((app) => app.shape)) - .some((shape) => !shape[size]), - ); - - if (incompleteSizes.length > 0) { - logger.warn("Found items with incomplete sizes. Generating missing sizes.", { - boardId, - count: incompleteSizes.length, - sizes: incompleteSizes.join(", "), - }); - - incompleteSizes.forEach((size) => { - const columnCount = mapColumnCount(settings.customization.gridstack, size); - const previousSize = !incompleteSizes.includes("lg") ? "lg" : incompleteSizes.includes("sm") ? "md" : "sm"; - const previousWidth = mapColumnCount(settings.customization.gridstack, previousSize); - logger.info("Generating missing size", { boardId, from: previousSize, to: size }); - - const items = widgets - .map((item) => mapItemForGridAlgorithm(item, previousSize)) - .concat(apps.map((item) => mapItemForGridAlgorithm(item, previousSize))); - - const distinctSectionIds = [...new Set(items.map((item) => item.sectionId))]; - distinctSectionIds.forEach((sectionId) => { - const { items: newItems } = generateResponsiveGridFor({ items, previousWidth, width: columnCount, sectionId }); - - localApps = localApps.map((app) => { - const item = newItems.find((item) => item.id === app.id); - if (!item) return app; - - return { - ...app, - shape: { - ...app.shape, - [size]: mapShapeFromGridAlgorithm(item), - }, - }; - }); - - localWidgets = localWidgets.map((widget) => { - const item = newItems.find((item) => item.id === widget.id); - if (!item) return widget; - - return { - ...widget, - shape: { - ...widget.shape, - [size]: mapShapeFromGridAlgorithm(item), - }, - }; - }); - }); - }); - } - - return localWidgets - .map((widget) => mapWidget(widget, appsMap, sectionMap, layoutMap, boardId)) - .concat(localApps.map((app) => mapApp(app, appsMap, sectionMap, layoutMap, boardId))) - .filter((widget) => widget !== null); -}; - -const mapItemForGridAlgorithm = (item: OldmarrApp | OldmarrWidget, size: BoardSize): GridAlgorithmItem => ({ - width: item.shape[size]?.size.width ?? 1, - height: item.shape[size]?.size.height ?? 1, - xOffset: item.shape[size]?.location.x ?? 0, - yOffset: item.shape[size]?.location.y ?? 0, - sectionId: item.area.type === "sidebar" ? item.area.properties.location : item.area.properties.id, - id: item.id, - type: "item", -}); - -const mapShapeFromGridAlgorithm = (item: GridAlgorithmItem) => - ({ - location: { - x: item.xOffset, - y: item.yOffset, - }, - size: { - width: item.width, - height: item.height, - }, - }) satisfies SizedShape; diff --git a/packages/old-import/src/prepare/prepare-multiple.ts b/packages/old-import/src/prepare/prepare-multiple.ts deleted file mode 100644 index 860d65b75..000000000 --- a/packages/old-import/src/prepare/prepare-multiple.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { AnalyseConfig, ValidAnalyseConfig } from "../analyse/types"; -import type { BoardSelectionMap } from "../components/initial/board-selection-card"; -import type { InitialOldmarrImportSettings } from "../settings"; -import { prepareApps } from "./prepare-apps"; -import { prepareBoards } from "./prepare-boards"; -import { prepareIntegrations } from "./prepare-integrations"; - -export const prepareMultipleImports = ( - analyseConfigs: AnalyseConfig[], - settings: InitialOldmarrImportSettings, - selections: BoardSelectionMap, -) => { - const invalidConfigs = analyseConfigs.filter((item) => item.config === null); - invalidConfigs.forEach(({ name }) => { - console.warn(`Skipping import of ${name} due to error in configuration. See logs of container for more details.`); - }); - - const filteredConfigs = analyseConfigs.filter((item): item is ValidAnalyseConfig => item.config !== null); - - return { - preparedApps: prepareApps(filteredConfigs), - preparedBoards: settings.onlyImportApps ? [] : prepareBoards(filteredConfigs, selections), - preparedIntegrations: prepareIntegrations(filteredConfigs), - }; -}; diff --git a/packages/old-import/src/prepare/prepare-sections.spec.ts b/packages/old-import/src/prepare/prepare-sections.spec.ts deleted file mode 100644 index df5bfceb2..000000000 --- a/packages/old-import/src/prepare/prepare-sections.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { parse } from "superjson"; -import { describe, expect, test } from "vitest"; - -import { placePreparedSections, prepareSections } from "./prepare-sections"; - -describe("prepareSections", () => { - test("maps legacy blocks into one root and durable containers", () => { - const prepared = prepareSections("board", { - categories: [{ id: "category", name: "Media", position: 1 }], - wrappers: [{ id: "wrapper", position: 0 }], - }); - const root = prepared.sections.find((section) => section.kind === "empty"); - const container = prepared.sections.find((section) => section.kind === "container"); - if (!root || !container) throw new Error("Expected a root and a container"); - - expect(prepared.sections).toHaveLength(2); - expect(root).toMatchObject({ boardId: "board", xOffset: 0, yOffset: 0 }); - expect(container).toMatchObject({ boardId: "board", xOffset: null, yOffset: null, name: null }); - expect(parse(container.options ?? "{}")).toMatchObject({ - title: "Media", - showLabel: true, - collapsible: true, - showOpenAll: true, - }); - expect(prepared.byLegacyId.get("category")?.id).toBe(container.id); - expect(prepared.byLegacyId.get("wrapper")?.id).not.toBe(root.id); - }); - - test("places root content and containers in legacy order for every layout", () => { - const prepared = prepareSections("board", { - categories: [{ id: "category", name: "Media", position: 1 }], - wrappers: [{ id: "wrapper", position: 0 }], - }); - const wrapperPlacementId = prepared.byLegacyId.get("wrapper")?.id; - const containerId = prepared.byLegacyId.get("category")?.id; - if (!wrapperPlacementId || !containerId) throw new Error("Expected mapped section ids"); - - const preparedItems = [ - { - layouts: [ - { - itemId: "root-item", - layoutId: "layout", - sectionId: wrapperPlacementId, - xOffset: 1, - yOffset: 0, - width: 2, - height: 2, - }, - ], - }, - { - layouts: [ - { - itemId: "container-item", - layoutId: "layout", - sectionId: containerId, - xOffset: 0, - yOffset: 1, - width: 3, - height: 2, - }, - ], - }, - ]; - - const layouts = placePreparedSections(prepared, preparedItems, [{ id: "layout", columnCount: 12 }]); - - expect(preparedItems[0]?.layouts[0]).toMatchObject({ - sectionId: prepared.mainRootId, - xOffset: 1, - yOffset: 0, - }); - expect(preparedItems[1]?.layouts[0]).toMatchObject({ sectionId: containerId, yOffset: 1 }); - expect(layouts).toEqual([ - { - sectionId: containerId, - layoutId: "layout", - parentSectionId: prepared.mainRootId, - xOffset: 0, - yOffset: 2, - width: 12, - height: 3, - }, - ]); - }); -}); diff --git a/packages/old-import/src/prepare/prepare-sections.ts b/packages/old-import/src/prepare/prepare-sections.ts deleted file mode 100644 index 176ce4511..000000000 --- a/packages/old-import/src/prepare/prepare-sections.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { createId } from "@homarr/common"; -import type { InferInsertModel } from "@homarr/db"; -import type { itemLayouts, sectionLayouts, sections } from "@homarr/db/schema"; -import type { OldmarrConfig } from "@homarr/old-schema"; - -import { mapLegacyCategoryContainer, mapMainRootSection } from "../mappers/map-section"; - -interface PreparedSectionBlock { - id: string; - legacyId: string; - position: number; - type: "container" | "root-content"; -} - -interface PreparedSections { - blocks: PreparedSectionBlock[]; - byLegacyId: Map; - mainRootId: string; - sections: InferInsertModel[]; -} - -interface PreparedItemWithLayouts { - layouts: InferInsertModel[]; -} - -export const prepareSections = ( - boardId: string, - { categories, wrappers }: Pick, -): PreparedSections => { - const mainRoot = mapMainRootSection(boardId); - const containers = categories.map((category) => ({ - category, - section: mapLegacyCategoryContainer(boardId, category), - })); - const rootContent = wrappers.map((wrapper) => ({ wrapper, id: createId() })); - - return { - mainRootId: mainRoot.id, - sections: [mainRoot, ...containers.map(({ section }) => section)], - byLegacyId: new Map([ - ...containers.map(({ category, section }) => [category.id, { id: section.id }] as const), - ...rootContent.map(({ wrapper, id }) => [wrapper.id, { id }] as const), - ]), - blocks: [ - ...containers.map(({ category, section }) => ({ - id: section.id, - legacyId: category.id, - position: category.position, - type: "container" as const, - })), - ...rootContent.map(({ wrapper, id }) => ({ - id, - legacyId: wrapper.id, - position: wrapper.position, - type: "root-content" as const, - })), - ].toSorted( - (first, second) => - first.position - second.position || - (first.type === second.type - ? first.legacyId.localeCompare(second.legacyId) - : first.type === "container" - ? -1 - : 1), - ), - }; -}; - -/** - * Old Homarr stored each top-level block in its own coordinate space. The - * current model has one main root and places labelled containers inside it. - * This compacts those blocks in their legacy order without changing the - * coordinates of content inside a container. - */ -export const placePreparedSections = ( - preparedSections: PreparedSections, - preparedItems: PreparedItemWithLayouts[], - preparedLayouts: { id: string; columnCount: number }[], -): InferInsertModel[] => { - const result: InferInsertModel[] = []; - - for (const layout of preparedLayouts) { - let nextRow = 0; - - for (const block of preparedSections.blocks) { - const content = preparedItems.flatMap((item) => - item.layouts.filter((itemLayout) => itemLayout.layoutId === layout.id && itemLayout.sectionId === block.id), - ); - const contentHeight = Math.max(0, ...content.map((itemLayout) => itemLayout.yOffset + itemLayout.height)); - - if (block.type === "container") { - const height = Math.max(1, contentHeight); - result.push({ - sectionId: block.id, - layoutId: layout.id, - parentSectionId: preparedSections.mainRootId, - xOffset: 0, - yOffset: nextRow, - width: layout.columnCount, - height, - }); - nextRow += height; - continue; - } - - for (const itemLayout of content) { - itemLayout.sectionId = preparedSections.mainRootId; - itemLayout.yOffset += nextRow; - } - nextRow += contentHeight; - } - } - - return result; -}; diff --git a/packages/old-import/src/prepare/prepare-single.ts b/packages/old-import/src/prepare/prepare-single.ts deleted file mode 100644 index cf1544555..000000000 --- a/packages/old-import/src/prepare/prepare-single.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { OldmarrConfig } from "@homarr/old-schema"; - -import type { OldmarrImportConfiguration } from "../settings"; -import { prepareApps } from "./prepare-apps"; - -export const prepareSingleImport = (config: OldmarrConfig, settings: OldmarrImportConfiguration) => { - const validAnalyseConfigs = [{ name: settings.name, config, isError: false }]; - - return { - preparedApps: prepareApps(validAnalyseConfigs), - preparedBoards: settings.onlyImportApps ? [] : validAnalyseConfigs, - }; -}; diff --git a/packages/old-import/src/settings.ts b/packages/old-import/src/settings.ts deleted file mode 100644 index e1bbae154..000000000 --- a/packages/old-import/src/settings.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { zfd } from "zod-form-data"; -import { z } from "zod/v4"; - -import { boardNameSchema } from "@homarr/validation/board"; -import { createCustomErrorParams } from "@homarr/validation/form/i18n"; - -export const sidebarBehaviours = ["remove-items", "last-section"] as const; -export const defaultSidebarBehaviour = "last-section"; -export type SidebarBehaviour = (typeof sidebarBehaviours)[number]; - -export const oldmarrImportConfigurationSchema = z.object({ - name: boardNameSchema, - onlyImportApps: z.boolean().default(false), - sidebarBehaviour: z.enum(sidebarBehaviours).default(defaultSidebarBehaviour), -}); - -export type OldmarrImportConfiguration = z.infer; - -export const initialOldmarrImportSettings = oldmarrImportConfigurationSchema.pick({ - onlyImportApps: true, - sidebarBehaviour: true, -}); - -export type InitialOldmarrImportSettings = z.infer; - -export const checkJsonImportFile: z.core.CheckFn = (context) => { - if (context.value.type !== "application/json") { - context.issues.push({ - code: "custom", - params: createCustomErrorParams({ - key: "invalidFileType", - params: { expected: "JSON" }, - }), - input: context.value.type, - }); - return; - } - - if (context.value.size > 1024 * 1024) { - context.issues.push({ - code: "custom", - params: createCustomErrorParams({ - key: "fileTooLarge", - params: { maxSize: "1 MB" }, - }), - input: context.value.size, - }); - return; - } -}; - -export const importJsonFileSchema = zfd.formData({ - file: zfd.file().check(checkJsonImportFile), - configuration: zfd.json(oldmarrImportConfigurationSchema), -}); diff --git a/packages/old-import/src/shared.ts b/packages/old-import/src/shared.ts deleted file mode 100644 index e752b57f8..000000000 --- a/packages/old-import/src/shared.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { importJsonFileSchema, checkJsonImportFile, oldmarrImportConfigurationSchema } from "./settings"; -export type { OldmarrImportConfiguration } from "./settings"; diff --git a/packages/old-import/src/user-schema.ts b/packages/old-import/src/user-schema.ts deleted file mode 100644 index 1bbe91211..000000000 --- a/packages/old-import/src/user-schema.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { z } from "zod/v4"; - -import { requiredNullableEmailSchema } from "@homarr/validation/email"; - -const regexEncryptedSchema = z.string().regex(/^[a-f0-9]+\.[a-f0-9]+$/g); - -const encryptedSchema = z.custom<`${string}.${string}`>((value) => regexEncryptedSchema.safeParse(value).success); - -export const oldmarrImportUserSchema = z.object({ - id: z.string(), - name: z.string(), - email: requiredNullableEmailSchema, - emailVerified: z.date().nullable(), - image: z.string().nullable(), - isAdmin: z.boolean(), - isOwner: z.boolean(), - settings: z - .object({ - colorScheme: z.enum(["environment", "light", "dark"]), - defaultBoard: z.string(), - firstDayOfWeek: z.enum(["monday", "saturday", "sunday"]), - replacePingWithIcons: z.boolean(), - }) - .nullable(), - password: encryptedSchema, - salt: encryptedSchema, -}); - -export type OldmarrImportUser = z.infer; diff --git a/packages/old-import/src/widgets/definitions/bookmark.ts b/packages/old-import/src/widgets/definitions/bookmark.ts deleted file mode 100644 index f97d2ba3b..000000000 --- a/packages/old-import/src/widgets/definitions/bookmark.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrBookmarkDefinition = CommonOldmarrWidgetDefinition< - "bookmark", - { - name: string; - items: { - id: string; - name: string; - href: string; - iconUrl: string; - openNewTab: boolean; - hideHostname: boolean; - hideIcon: boolean; - }[]; - layout: "autoGrid" | "horizontal" | "vertical"; - } ->; - -export type BookmarkApp = OldmarrBookmarkDefinition["options"]["items"][number]; diff --git a/packages/old-import/src/widgets/definitions/calendar.ts b/packages/old-import/src/widgets/definitions/calendar.ts deleted file mode 100644 index 0220cd832..000000000 --- a/packages/old-import/src/widgets/definitions/calendar.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrCalendarDefinition = CommonOldmarrWidgetDefinition< - "calendar", - { - hideWeekDays: boolean; - showUnmonitored: boolean; - radarrReleaseType: "inCinemas" | "physicalRelease" | "digitalRelease"; - fontSize: "xs" | "sm" | "md" | "lg" | "xl"; - } ->; diff --git a/packages/old-import/src/widgets/definitions/common.ts b/packages/old-import/src/widgets/definitions/common.ts deleted file mode 100644 index 254966e24..000000000 --- a/packages/old-import/src/widgets/definitions/common.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { OldmarrWidgetKind } from "@homarr/old-schema"; - -export interface CommonOldmarrWidgetDefinition< - TId extends OldmarrWidgetKind, - TOptions extends Record, -> { - id: TId; - options: TOptions; -} diff --git a/packages/old-import/src/widgets/definitions/dashdot.ts b/packages/old-import/src/widgets/definitions/dashdot.ts deleted file mode 100644 index 229f194d8..000000000 --- a/packages/old-import/src/widgets/definitions/dashdot.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrDashdotDefinition = CommonOldmarrWidgetDefinition< - "dashdot", - { - dashName: string; - url: string; - usePercentages: boolean; - columns: number; - graphHeight: number; - graphsOrder: ( - | { - key: "storage"; - subValues: { - enabled: boolean; - compactView: boolean; - span: number; - multiView: boolean; - }; - } - | { - key: "network"; - subValues: { - enabled: boolean; - compactView: boolean; - span: number; - }; - } - | { - key: "cpu"; - subValues: { - enabled: boolean; - multiView: boolean; - span: number; - }; - } - | { - key: "ram"; - subValues: { - enabled: boolean; - span: number; - }; - } - | { - key: "gpu"; - subValues: { - enabled: boolean; - span: number; - }; - } - )[]; - } ->; diff --git a/packages/old-import/src/widgets/definitions/date.ts b/packages/old-import/src/widgets/definitions/date.ts deleted file mode 100644 index b43594a02..000000000 --- a/packages/old-import/src/widgets/definitions/date.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrDateDefinition = CommonOldmarrWidgetDefinition< - "date", - { - timezone: string; - customTitle: string; - display24HourFormat: boolean; - dateFormat: - | "hide" - | "dddd, MMMM D" - | "dddd, D MMMM" - | "MMM D" - | "D MMM" - | "DD/MM/YYYY" - | "MM/DD/YYYY" - | "DD/MM" - | "MM/DD"; - titleState: "none" | "city" | "both"; - } ->; diff --git a/packages/old-import/src/widgets/definitions/dlspeed.ts b/packages/old-import/src/widgets/definitions/dlspeed.ts deleted file mode 100644 index d77f33df9..000000000 --- a/packages/old-import/src/widgets/definitions/dlspeed.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export type OldmarrDlspeedDefinition = CommonOldmarrWidgetDefinition<"dlspeed", {}>; diff --git a/packages/old-import/src/widgets/definitions/dns-hole-controls.ts b/packages/old-import/src/widgets/definitions/dns-hole-controls.ts deleted file mode 100644 index cb16aa36d..000000000 --- a/packages/old-import/src/widgets/definitions/dns-hole-controls.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrDnsHoleControlsDefinition = CommonOldmarrWidgetDefinition< - "dns-hole-controls", - { - showToggleAllButtons: boolean; - } ->; diff --git a/packages/old-import/src/widgets/definitions/dns-hole-summary.ts b/packages/old-import/src/widgets/definitions/dns-hole-summary.ts deleted file mode 100644 index 94190bf6f..000000000 --- a/packages/old-import/src/widgets/definitions/dns-hole-summary.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrDnsHoleSummaryDefinition = CommonOldmarrWidgetDefinition< - "dns-hole-summary", - { usePiHoleColors: boolean; layout: "column" | "row" | "grid" } ->; diff --git a/packages/old-import/src/widgets/definitions/health-monitoring.ts b/packages/old-import/src/widgets/definitions/health-monitoring.ts deleted file mode 100644 index f310bb9dc..000000000 --- a/packages/old-import/src/widgets/definitions/health-monitoring.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrHealthMonitoringDefinition = CommonOldmarrWidgetDefinition< - "health-monitoring", - { - fahrenheit: boolean; - cpu: boolean; - memory: boolean; - fileSystem: boolean; - defaultTabState: "system" | "cluster"; - node: string; - defaultViewState: "storage" | "none" | "node" | "vm" | "lxc"; - summary: boolean; - showNode: boolean; - showVM: boolean; - showLXCs: boolean; - showStorage: boolean; - sectionIndicatorColor: "all" | "any"; - ignoreCert: boolean; - } ->; diff --git a/packages/old-import/src/widgets/definitions/iframe.ts b/packages/old-import/src/widgets/definitions/iframe.ts deleted file mode 100644 index 21e29e83d..000000000 --- a/packages/old-import/src/widgets/definitions/iframe.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrIframeDefinition = CommonOldmarrWidgetDefinition< - "iframe", - { - embedUrl: string; - allowFullScreen: boolean; - allowScrolling: boolean; - allowTransparency: boolean; - allowPayment: boolean; - allowAutoPlay: boolean; - allowMicrophone: boolean; - allowCamera: boolean; - allowGeolocation: boolean; - } ->; diff --git a/packages/old-import/src/widgets/definitions/index.ts b/packages/old-import/src/widgets/definitions/index.ts deleted file mode 100644 index 2a11eb1ac..000000000 --- a/packages/old-import/src/widgets/definitions/index.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { objectEntries } from "@homarr/common"; -import type { Inverse } from "@homarr/common/types"; -import type { WidgetKind } from "@homarr/definitions"; - -import type { OldmarrBookmarkDefinition } from "./bookmark"; -import type { OldmarrCalendarDefinition } from "./calendar"; -import type { OldmarrDashdotDefinition } from "./dashdot"; -import type { OldmarrDateDefinition } from "./date"; -import type { OldmarrDlspeedDefinition } from "./dlspeed"; -import type { OldmarrDnsHoleControlsDefinition } from "./dns-hole-controls"; -import type { OldmarrDnsHoleSummaryDefinition } from "./dns-hole-summary"; -import type { OldmarrHealthMonitoringDefinition } from "./health-monitoring"; -import type { OldmarrIframeDefinition } from "./iframe"; -import type { OldmarrIndexerManagerDefinition } from "./indexer-manager"; -import type { OldmarrMediaRequestListDefinition } from "./media-requests-list"; -import type { OldmarrMediaRequestStatsDefinition } from "./media-requests-stats"; -import type { OldmarrMediaServerDefinition } from "./media-server"; -import type { OldmarrMediaTranscodingDefinition } from "./media-transcoding"; -import type { OldmarrNotebookDefinition } from "./notebook"; -import type { OldmarrRssDefinition } from "./rss"; -import type { OldmarrSmartHomeEntityStateDefinition } from "./smart-home-entity-state"; -import type { OldmarrSmartHomeTriggerAutomationDefinition } from "./smart-home-trigger-automation"; -import type { OldmarrTorrentStatusDefinition } from "./torrent-status"; -import type { OldmarrUsenetDefinition } from "./usenet"; -import type { OldmarrVideoStreamDefinition } from "./video-stream"; -import type { OldmarrWeatherDefinition } from "./weather"; - -export type OldmarrWidgetDefinitions = - | OldmarrWeatherDefinition - | OldmarrDateDefinition - | OldmarrCalendarDefinition - | OldmarrIndexerManagerDefinition - | OldmarrDashdotDefinition - | OldmarrUsenetDefinition - | OldmarrTorrentStatusDefinition - | OldmarrDlspeedDefinition - | OldmarrRssDefinition - | OldmarrVideoStreamDefinition - | OldmarrIframeDefinition - | OldmarrMediaServerDefinition - | OldmarrMediaRequestListDefinition - | OldmarrMediaRequestStatsDefinition - | OldmarrDnsHoleSummaryDefinition - | OldmarrDnsHoleControlsDefinition - | OldmarrBookmarkDefinition - | OldmarrNotebookDefinition - | OldmarrSmartHomeEntityStateDefinition - | OldmarrSmartHomeTriggerAutomationDefinition - | OldmarrHealthMonitoringDefinition - | OldmarrMediaTranscodingDefinition; - -export const widgetKindMapping = { - date: "clock", - calendar: "calendar", - "torrents-status": "downloads", - weather: "weather", - rss: "rssFeed", - "video-stream": "video", - iframe: "iframe", - "media-server": "mediaServer", - "dns-hole-summary": "dnsHoleSummary", - "dns-hole-controls": "dnsHoleControls", - notebook: "notebook", - "smart-home/entity-state": "smartHome-entityState", - "smart-home/trigger-automation": "smartHome-executeAutomation", - "media-requests-list": "mediaRequests-requestList", - "media-requests-stats": "mediaRequests-requestStats", - "indexer-manager": "indexerManager", - bookmark: "bookmarks", - "health-monitoring": "healthMonitoring", - dashdot: "healthMonitoring", - "media-transcoding": "mediaTranscoding", - dlspeed: null, - usenet: "downloads", -} satisfies Record; - -export type WidgetMapping = typeof widgetKindMapping; -export type InversedWidgetMapping = Inverse>; - -export const mapKind = (kind: OldmarrWidgetDefinitions["id"]): keyof InversedWidgetMapping | null => - objectEntries(widgetKindMapping).find(([key]) => key === kind)?.[1] ?? null; diff --git a/packages/old-import/src/widgets/definitions/indexer-manager.ts b/packages/old-import/src/widgets/definitions/indexer-manager.ts deleted file mode 100644 index 2061a8cfa..000000000 --- a/packages/old-import/src/widgets/definitions/indexer-manager.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrIndexerManagerDefinition = CommonOldmarrWidgetDefinition< - "indexer-manager", - { - openIndexerSiteInNewTab: boolean; - } ->; diff --git a/packages/old-import/src/widgets/definitions/media-requests-list.ts b/packages/old-import/src/widgets/definitions/media-requests-list.ts deleted file mode 100644 index a8f8a7fc1..000000000 --- a/packages/old-import/src/widgets/definitions/media-requests-list.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrMediaRequestListDefinition = CommonOldmarrWidgetDefinition< - "media-requests-list", - { - replaceLinksWithExternalHost: boolean; - openInNewTab: boolean; - } ->; diff --git a/packages/old-import/src/widgets/definitions/media-requests-stats.ts b/packages/old-import/src/widgets/definitions/media-requests-stats.ts deleted file mode 100644 index 12fd9f388..000000000 --- a/packages/old-import/src/widgets/definitions/media-requests-stats.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrMediaRequestStatsDefinition = CommonOldmarrWidgetDefinition< - "media-requests-stats", - { - replaceLinksWithExternalHost: boolean; - openInNewTab: boolean; - } ->; diff --git a/packages/old-import/src/widgets/definitions/media-server.ts b/packages/old-import/src/widgets/definitions/media-server.ts deleted file mode 100644 index 4c3d46fd2..000000000 --- a/packages/old-import/src/widgets/definitions/media-server.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export type OldmarrMediaServerDefinition = CommonOldmarrWidgetDefinition<"media-server", {}>; diff --git a/packages/old-import/src/widgets/definitions/media-transcoding.ts b/packages/old-import/src/widgets/definitions/media-transcoding.ts deleted file mode 100644 index 82f278745..000000000 --- a/packages/old-import/src/widgets/definitions/media-transcoding.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrMediaTranscodingDefinition = CommonOldmarrWidgetDefinition< - "media-transcoding", - { - defaultView: "workers" | "queue" | "statistics"; - showHealthCheck: boolean; - showHealthChecksInQueue: boolean; - queuePageSize: number; - showAppIcon: boolean; - } ->; diff --git a/packages/old-import/src/widgets/definitions/notebook.ts b/packages/old-import/src/widgets/definitions/notebook.ts deleted file mode 100644 index 6f589a3c0..000000000 --- a/packages/old-import/src/widgets/definitions/notebook.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrNotebookDefinition = CommonOldmarrWidgetDefinition< - "notebook", - { - showToolbar: boolean; - allowReadOnlyCheck: boolean; - content: string; - } ->; diff --git a/packages/old-import/src/widgets/definitions/rss.ts b/packages/old-import/src/widgets/definitions/rss.ts deleted file mode 100644 index 1dbf9d560..000000000 --- a/packages/old-import/src/widgets/definitions/rss.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrRssDefinition = CommonOldmarrWidgetDefinition< - "rss", - { - rssFeedUrl: string[]; - enableRtl: boolean; - refreshInterval: number; - dangerousAllowSanitizedItemContent: boolean; - textLinesClamp: number; - sortByPublishDateAscending: boolean; - sortPostsWithoutPublishDateToTheTop: boolean; - maximumAmountOfPosts: number; - } ->; diff --git a/packages/old-import/src/widgets/definitions/smart-home-entity-state.ts b/packages/old-import/src/widgets/definitions/smart-home-entity-state.ts deleted file mode 100644 index 169b9c46d..000000000 --- a/packages/old-import/src/widgets/definitions/smart-home-entity-state.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrSmartHomeEntityStateDefinition = CommonOldmarrWidgetDefinition< - "smart-home/entity-state", - { - entityId: string; - appendUnit: boolean; - genericToggle: boolean; - automationId: string; - displayName: string; - displayFriendlyName: boolean; - } ->; diff --git a/packages/old-import/src/widgets/definitions/smart-home-trigger-automation.ts b/packages/old-import/src/widgets/definitions/smart-home-trigger-automation.ts deleted file mode 100644 index 5991e4631..000000000 --- a/packages/old-import/src/widgets/definitions/smart-home-trigger-automation.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrSmartHomeTriggerAutomationDefinition = CommonOldmarrWidgetDefinition< - "smart-home/trigger-automation", - { - automationId: string; - displayName: string; - } ->; diff --git a/packages/old-import/src/widgets/definitions/torrent-status.ts b/packages/old-import/src/widgets/definitions/torrent-status.ts deleted file mode 100644 index 8e7adb341..000000000 --- a/packages/old-import/src/widgets/definitions/torrent-status.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrTorrentStatusDefinition = CommonOldmarrWidgetDefinition< - "torrents-status", - { - displayCompletedTorrents: boolean; - displayActiveTorrents: boolean; - speedLimitOfActiveTorrents: number; - displayStaleTorrents: boolean; - labelFilterIsWhitelist: boolean; - labelFilter: string[]; - displayRatioWithFilter: boolean; - columnOrdering: boolean; - rowSorting: boolean; - columns: ("up" | "down" | "eta" | "progress")[]; - nameColumnSize: number; - } ->; diff --git a/packages/old-import/src/widgets/definitions/usenet.ts b/packages/old-import/src/widgets/definitions/usenet.ts deleted file mode 100644 index 3e9e9ec5c..000000000 --- a/packages/old-import/src/widgets/definitions/usenet.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export type OldmarrUsenetDefinition = CommonOldmarrWidgetDefinition<"usenet", {}>; diff --git a/packages/old-import/src/widgets/definitions/video-stream.ts b/packages/old-import/src/widgets/definitions/video-stream.ts deleted file mode 100644 index 6294e2168..000000000 --- a/packages/old-import/src/widgets/definitions/video-stream.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrVideoStreamDefinition = CommonOldmarrWidgetDefinition< - "video-stream", - { - FeedUrl: string; - autoPlay: boolean; - muted: boolean; - controls: boolean; - } ->; diff --git a/packages/old-import/src/widgets/definitions/weather.ts b/packages/old-import/src/widgets/definitions/weather.ts deleted file mode 100644 index 099e1468b..000000000 --- a/packages/old-import/src/widgets/definitions/weather.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { CommonOldmarrWidgetDefinition } from "./common"; - -export type OldmarrWeatherDefinition = CommonOldmarrWidgetDefinition< - "weather", - { - displayInFahrenheit: boolean; - displayCityName: boolean; - displayWeekly: boolean; - forecastDays: number; - location: { - name: string; - latitude: number; - longitude: number; - }; - dateFormat: - | "hide" - | "dddd, MMMM D" - | "dddd, D MMMM" - | "MMM D" - | "D MMM" - | "DD/MM/YYYY" - | "MM/DD/YYYY" - | "DD/MM" - | "MM/DD"; - } ->; diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts deleted file mode 100644 index b2ed298a7..000000000 --- a/packages/old-import/src/widgets/options.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { objectEntries } from "@homarr/common"; -import { createLogger } from "@homarr/core/infrastructure/logs"; - -import type { WidgetComponentProps } from "../../../widgets/src/definition"; -import type { InversedWidgetMapping, OldmarrWidgetDefinitions, WidgetMapping } from "./definitions"; -import { mapKind } from "./definitions"; - -const logger = createLogger({ module: "mapOptions" }); - -// This type enforces, that for all widget mappings there is a corresponding option mapping, -// each option of newmarr can be mapped from the value of the oldmarr options -type OptionMapping = { - [WidgetKey in keyof InversedWidgetMapping]: InversedWidgetMapping[WidgetKey] extends null - ? null - : { - [OptionsKey in keyof WidgetComponentProps["options"]]: ( - oldOptions: Extract["options"], - appsMap: Map, - ) => WidgetComponentProps["options"][OptionsKey] | undefined; - }; -}; - -const optionMapping: OptionMapping = { - "mediaRequests-requestList": { - linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab, - statusFilter: () => undefined, - recentDays: () => undefined, - }, - "mediaRequests-requestStats": {}, - bookmarks: { - title: (oldOptions) => oldOptions.name, - // It's safe to assume that the app exists, because the app is always created before the widget - // And the mapping is created in insertAppsAsync - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - items: (oldOptions, appsMap) => oldOptions.items.map((item) => appsMap.get(item.id)!), - layout: (oldOptions) => { - const mappedLayouts: Record["options"]["layout"]> = { - autoGrid: "grid", - horizontal: "row", - vertical: "column", - }; - - return mappedLayouts[oldOptions.layout]; - }, - hideTitle: () => undefined, - hideIcon: (oldOptions) => oldOptions.items.some((item) => item.hideIcon), - hideHostname: (oldOptions) => oldOptions.items.some((item) => item.hideHostname), - openNewTab: (oldOptions) => oldOptions.items.some((item) => item.openNewTab), - withBorder: () => undefined, - }, - calendar: { - releaseType: (oldOptions) => [oldOptions.radarrReleaseType], - filterFutureMonths: () => undefined, - filterPastMonths: () => undefined, - showUnmonitored: ({ showUnmonitored }) => showUnmonitored, - }, - clock: { - customTitle: (oldOptions) => oldOptions.customTitle, - customTitleToggle: (oldOptions) => oldOptions.titleState !== "none", - dateFormat: (oldOptions) => (oldOptions.dateFormat === "hide" ? undefined : oldOptions.dateFormat), - is24HourFormat: (oldOptions) => oldOptions.display24HourFormat, - showDate: (oldOptions) => oldOptions.dateFormat !== "hide", - showSeconds: () => undefined, - timezone: (oldOptions) => oldOptions.timezone, - useCustomTimezone: () => true, - customTimeFormat: () => undefined, - customDateFormat: () => undefined, - showWeather: () => undefined, - weatherLocation: () => undefined, - isWeatherFormatFahrenheit: () => undefined, - }, - downloads: { - activeTorrentThreshold: (oldOptions) => - "speedLimitOfActiveTorrents" in oldOptions ? oldOptions.speedLimitOfActiveTorrents : undefined, - applyFilterToRatio: (oldOptions) => - "displayRatioWithFilter" in oldOptions ? oldOptions.displayRatioWithFilter : undefined, - categoryFilter: (oldOptions) => ("labelFilter" in oldOptions ? oldOptions.labelFilter : undefined), - filterIsWhitelist: (oldOptions) => - "labelFilterIsWhitelist" in oldOptions ? oldOptions.labelFilterIsWhitelist : undefined, - showCompletedTorrent: (oldOptions) => - "displayCompletedTorrents" in oldOptions ? oldOptions.displayCompletedTorrents : undefined, - columns: () => ["integration", "name", "progress", "time", "state"], - defaultSort: () => "progress", - descendingDefaultSort: () => false, - showCompletedUsenet: () => true, - showCompletedHttp: () => true, - limitPerIntegration: () => undefined, - columnOrder: () => undefined, - columnWidths: () => undefined, - }, - weather: { - forecastDayCount: (oldOptions) => oldOptions.forecastDays, - hasForecast: (oldOptions) => oldOptions.displayWeekly, - isFormatFahrenheit: (oldOptions) => oldOptions.displayInFahrenheit, - disableTemperatureDecimals: () => undefined, - showCurrentWindSpeed: () => undefined, - location: (oldOptions) => oldOptions.location, - showCity: (oldOptions) => oldOptions.displayCityName, - dateFormat: (oldOptions) => (oldOptions.dateFormat === "hide" ? undefined : oldOptions.dateFormat), - useImperialSpeed: () => undefined, - }, - iframe: { - embedUrl: (oldOptions) => oldOptions.embedUrl, - allowAutoPlay: (oldOptions) => oldOptions.allowAutoPlay, - allowFullScreen: (oldOptions) => oldOptions.allowFullScreen, - allowPayment: (oldOptions) => oldOptions.allowPayment, - allowCamera: (oldOptions) => oldOptions.allowCamera, - allowMicrophone: (oldOptions) => oldOptions.allowMicrophone, - allowGeolocation: (oldOptions) => oldOptions.allowGeolocation, - allowScrolling: (oldOptions) => oldOptions.allowScrolling, - allowModals: () => undefined, - }, - video: { - feedUrl: (oldOptions) => oldOptions.FeedUrl, - hasAutoPlay: (oldOptions) => oldOptions.autoPlay, - hasControls: (oldOptions) => oldOptions.controls, - isMuted: (oldOptions) => oldOptions.muted, - }, - dnsHoleControls: { - showToggleAllButtons: (oldOptions) => oldOptions.showToggleAllButtons, - }, - dnsHoleSummary: { - layout: (oldOptions) => oldOptions.layout, - usePiHoleColors: (oldOptions) => oldOptions.usePiHoleColors, - }, - rssFeed: { - feedUrls: (oldOptions) => oldOptions.rssFeedUrl, - enableRtl: (oldOptions) => oldOptions.enableRtl, - maximumAmountPosts: (oldOptions) => oldOptions.maximumAmountOfPosts, - textLinesClamp: (oldOptions) => oldOptions.textLinesClamp, - hideDescription: () => undefined, - showPosterImage: () => true, // This was the default in oldmarr - }, - notebook: { - allowReadOnlyCheck: (oldOptions) => oldOptions.allowReadOnlyCheck, - content: (oldOptions) => oldOptions.content, - showToolbar: (oldOptions) => oldOptions.showToolbar, - }, - "smartHome-entityState": { - entityId: (oldOptions) => oldOptions.entityId, - displayName: (oldOptions) => oldOptions.displayName, - clickable: () => undefined, - entityUnit: () => undefined, - }, - "smartHome-executeAutomation": { - automationId: (oldOptions) => oldOptions.automationId, - displayName: (oldOptions) => oldOptions.displayName, - }, - mediaServer: { - showOnlyPlaying: () => undefined, - showBitrate: () => undefined, - showLocation: () => undefined, - }, - indexerManager: { - openIndexerSiteInNewTab: (oldOptions) => oldOptions.openIndexerSiteInNewTab, - }, - healthMonitoring: { - cpu: (oldOptions) => - "cpu" in oldOptions - ? oldOptions.cpu - : oldOptions.graphsOrder.some((graph) => graph.key === "cpu" && graph.subValues.enabled), - memory: (oldOptions) => - "memory" in oldOptions - ? oldOptions.memory - : oldOptions.graphsOrder.some((graph) => graph.key === "ram" && graph.subValues.enabled), - fahrenheit: (oldOptions) => ("fahrenheit" in oldOptions ? oldOptions.fahrenheit : undefined), - fileSystem: (oldOptions) => - "fileSystem" in oldOptions - ? oldOptions.fileSystem - : oldOptions.graphsOrder.some((graph) => graph.key === "storage" && graph.subValues.enabled), - defaultTab: (oldOptions) => ("defaultTabState" in oldOptions ? oldOptions.defaultTabState : undefined), - sectionIndicatorRequirement: (oldOptions) => - "sectionIndicatorColor" in oldOptions ? oldOptions.sectionIndicatorColor : undefined, - showUptime: () => undefined, - gpu: () => undefined, - visibleStorageVolumes: () => undefined, - visibleClusterSections: (oldOptions) => { - if (!("showNode" in oldOptions)) return undefined; - - const oldKeys = { - showNode: "node" as const, - showLXCs: "lxc" as const, - showVM: "qemu" as const, - showStorage: "storage" as const, - } satisfies Partial>; - - return objectEntries(oldKeys) - .filter(([key]) => oldOptions[key]) - .map(([_, section]) => section); - }, - }, - mediaTranscoding: { - defaultView: (oldOptions) => oldOptions.defaultView, - queuePageSize: (oldOptions) => oldOptions.queuePageSize, - }, -}; - -/** - * Maps the oldmarr options to the newmarr options - * @param type old widget type - * @param oldOptions oldmarr options for this item - * @param appsMap map of old app ids to new app ids - * @returns newmarr options for this item or null if the item did not exist in oldmarr - */ -export const mapOptions = ( - type: K, - oldOptions: Extract["options"], - appsMap: Map, -) => { - logger.debug("Mapping old homarr options for widget", { type, options: JSON.stringify(oldOptions) }); - const kind = mapKind(type); - if (!kind) { - return null; - } - - const mapping = optionMapping[kind]; - return objectEntries(mapping).reduce( - (acc, [key, value]: [string, (oldOptions: Record, appsMap: Map) => unknown]) => { - const newValue = value(oldOptions, appsMap); - logger.debug("Mapping old homarr option", { kind, key, newValue }); - if (newValue !== undefined) { - acc[key] = newValue; - } - return acc; - }, - {} as Record, - ) as WidgetComponentProps>["options"]; -}; diff --git a/packages/old-import/tsconfig.json b/packages/old-import/tsconfig.json deleted file mode 100644 index cbe8483d9..000000000 --- a/packages/old-import/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "@homarr/tsconfig/base.json", - "compilerOptions": { - "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" - }, - "include": ["*.ts", "src"], - "exclude": ["node_modules"] -} diff --git a/packages/old-schema/index.ts b/packages/old-schema/index.ts deleted file mode 100644 index 3bd16e178..000000000 --- a/packages/old-schema/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./src"; diff --git a/packages/old-schema/package.json b/packages/old-schema/package.json deleted file mode 100644 index 328bd8f50..000000000 --- a/packages/old-schema/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@homarr/old-schema", - "version": "0.1.0", - "private": true, - "license": "Apache-2.0", - "type": "module", - "typesVersions": { - "*": { - "*": [ - "src/*" - ] - } - }, - "exports": { - ".": "./index.ts" - }, - "scripts": { - "clean": "rm -rf .turbo node_modules", - "format": "oxfmt .", - "lint": "oxlint", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@homarr/common": "workspace:^0.1.0", - "zod": "catalog:" - }, - "devDependencies": { - "@homarr/tsconfig": "workspace:^0.1.0", - "typescript": "catalog:" - } -} diff --git a/packages/old-schema/src/app.ts b/packages/old-schema/src/app.ts deleted file mode 100644 index 088ccca0a..000000000 --- a/packages/old-schema/src/app.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { z } from "zod/v4"; - -import { tileBaseSchema } from "./tile"; - -const appBehaviourSchema = z.object({ - externalUrl: z.string(), - isOpeningNewTab: z.boolean().catch(true), - tooltipDescription: z.string().optional().catch(undefined), -}); - -const appNetworkSchema = z.object({ - enabledStatusChecker: z.boolean().catch(true), - okStatus: z.array(z.number()).optional().catch([]), - statusCodes: z.array(z.string()).catch([]), -}); - -const appAppearanceSchema = z.object({ - iconUrl: z.string(), - appNameStatus: z.union([z.literal("normal"), z.literal("hover"), z.literal("hidden")]).catch("normal"), - positionAppName: z - .union([z.literal("row"), z.literal("column"), z.literal("row-reverse"), z.literal("column-reverse")]) - .catch("column"), - appNameFontSize: z.number().catch(16), - lineClampAppName: z.number().catch(1), -}); - -const integrationSchema = z.enum([ - "readarr", - "radarr", - "sonarr", - "lidarr", - "prowlarr", - "sabnzbd", - "jellyseerr", - "overseerr", - "deluge", - "qBittorrent", - "transmission", - "plex", - "jellyfin", - "nzbGet", - "pihole", - "adGuardHome", - "homeAssistant", - "openmediavault", - "proxmox", - "tdarr", -]); - -export type OldmarrIntegrationType = z.infer; - -const appIntegrationPropertySchema = z.object({ - type: z.enum(["private", "public"]), - field: z.enum(["apiKey", "password", "username"]), - value: z.string().nullable().optional(), - isDefined: z.boolean().optional(), -}); - -const appIntegrationSchema = z.object({ - type: integrationSchema.optional().nullable(), - properties: z.array(appIntegrationPropertySchema), -}); - -export const oldmarrAppSchema = z - .object({ - id: z.string(), - name: z.string(), - url: z.string(), - behaviour: appBehaviourSchema, - network: appNetworkSchema, - appearance: appAppearanceSchema, - integration: appIntegrationSchema.optional().nullable(), - }) - .and(tileBaseSchema); - -export type OldmarrApp = z.infer; diff --git a/packages/old-schema/src/config.ts b/packages/old-schema/src/config.ts deleted file mode 100644 index f9fec28a8..000000000 --- a/packages/old-schema/src/config.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from "zod/v4"; - -import { oldmarrAppSchema } from "./app"; -import { settingsSchema } from "./setting"; -import { oldmarrWidgetSchema } from "./widget"; - -const categorySchema = z.object({ - id: z.string(), - position: z.number(), - name: z.string(), -}); - -const wrapperSchema = z.object({ - id: z.string(), - position: z.number(), -}); - -export const oldmarrConfigSchema = z.object({ - schemaVersion: z.number(), - configProperties: z.object({ - name: z.string(), - }), - categories: z.array(categorySchema), - wrappers: z.array(wrapperSchema), - apps: z.array(oldmarrAppSchema), - widgets: z.array(oldmarrWidgetSchema), - settings: settingsSchema, -}); - -export type OldmarrConfig = z.infer; -export type OldmarrCategorySection = z.infer; -export type OldmarrEmptySection = z.infer; diff --git a/packages/old-schema/src/index.ts b/packages/old-schema/src/index.ts deleted file mode 100644 index e79beb270..000000000 --- a/packages/old-schema/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type { OldmarrConfig, OldmarrCategorySection, OldmarrEmptySection } from "./config"; -export { oldmarrConfigSchema } from "./config"; -export type { OldmarrApp, OldmarrIntegrationType } from "./app"; -export type { OldmarrWidget, OldmarrWidgetKind } from "./widget"; -export { oldmarrWidgetKinds } from "./widget"; -export { boardSizes, getBoardSizeName } from "./tile"; -export type { BoardSize, SizedShape } from "./tile"; diff --git a/packages/old-schema/src/setting.ts b/packages/old-schema/src/setting.ts deleted file mode 100644 index 171ea3b43..000000000 --- a/packages/old-schema/src/setting.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { z } from "zod/v4"; - -const baseSearchEngineSchema = z.object({ - properties: z.object({ - openInNewTab: z.boolean().default(true), - enabled: z.boolean().default(true), - }), -}); - -const commonSearchEngineSchema = z - .object({ - type: z.enum(["google", "duckDuckGo", "bing"]), - }) - .and(baseSearchEngineSchema); - -const customSearchEngineSchema = z - .object({ - type: z.literal("custom"), - properties: z.object({ - template: z.string(), - }), - }) - .and(baseSearchEngineSchema); - -const searchEngineSchema = z.union([commonSearchEngineSchema, customSearchEngineSchema]); - -const commonSettingsSchema = z.object({ - searchEngine: searchEngineSchema, -}); - -const accessSettingsSchema = z.object({ - allowGuests: z.boolean(), -}); - -const gridstackSettingsSchema = z - .object({ - columnCountSmall: z.number(), - columnCountMedium: z.number(), - columnCountLarge: z.number(), - }) - .catch({ - columnCountSmall: 3, - columnCountMedium: 6, - columnCountLarge: 12, - }); - -const layoutSettingsSchema = z.object({ - enabledLeftSidebar: z.boolean(), - enabledRightSidebar: z.boolean(), - enabledDocker: z.boolean(), - enabledPing: z.boolean(), - enabledSearchbar: z.boolean(), -}); - -const colorsSettingsSchema = z.object({ - primary: z.string().optional(), - secondary: z.string().optional(), - shade: z.number().optional(), -}); - -const customizationSettingsSchema = z.object({ - layout: layoutSettingsSchema, - pageTitle: z.string().optional(), - metaTitle: z.string().optional(), - logoImageUrl: z.string().optional(), - faviconUrl: z.string().optional(), - backgroundImageUrl: z.string().optional(), - backgroundImageAttachment: z.enum(["fixed", "scroll"]).optional(), - backgroundImageSize: z.enum(["cover", "contain"]).optional(), - backgroundImageRepeat: z.enum(["no-repeat", "repeat", "repeat-x", "repeat-y"]).optional(), - customCss: z.string().optional(), - colors: colorsSettingsSchema, - appOpacity: z.number().optional(), - gridstack: gridstackSettingsSchema, -}); - -export const settingsSchema = z.object({ - common: commonSettingsSchema, - customization: customizationSettingsSchema, - access: accessSettingsSchema, -}); diff --git a/packages/old-schema/src/tile.ts b/packages/old-schema/src/tile.ts deleted file mode 100644 index 0d835c3ac..000000000 --- a/packages/old-schema/src/tile.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { z } from "zod/v4"; - -import { objectKeys } from "@homarr/common"; - -const createAreaSchema = ( - type: TType, - propertiesSchema: TPropertiesSchema, -) => - z.object({ - type: z.literal(type), - properties: propertiesSchema, - }); - -const wrapperAreaSchema = createAreaSchema( - "wrapper", - z.object({ - id: z.string(), - }), -); - -const categoryAreaSchema = createAreaSchema( - "category", - z.object({ - id: z.string(), - }), -); - -const sidebarAreaSchema = createAreaSchema( - "sidebar", - z.object({ - location: z.union([z.literal("right"), z.literal("left")]), - }), -); - -const areaSchema = z.union([wrapperAreaSchema, categoryAreaSchema, sidebarAreaSchema]); - -const sizedShapeSchema = z.object({ - location: z.object({ - x: z.number(), - y: z.number(), - }), - size: z.object({ - width: z.number(), - height: z.number(), - }), -}); - -const shapeSchema = z.object({ - lg: sizedShapeSchema.optional(), - md: sizedShapeSchema.optional(), - sm: sizedShapeSchema.optional(), -}); - -export const tileBaseSchema = z.object({ - area: areaSchema, - shape: shapeSchema, -}); - -export type SizedShape = z.infer; - -export const boardSizes = objectKeys(shapeSchema.def.shape); -export type BoardSize = (typeof boardSizes)[number]; - -export const getBoardSizeName = (size: BoardSize) => { - switch (size) { - case "md": - return "medium"; - case "sm": - return "small"; - case "lg": - default: - return "large"; - } -}; diff --git a/packages/old-schema/src/widget.ts b/packages/old-schema/src/widget.ts deleted file mode 100644 index dfff00751..000000000 --- a/packages/old-schema/src/widget.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { z } from "zod/v4"; - -import { tileBaseSchema } from "./tile"; - -export const oldmarrWidgetKinds = [ - "calendar", - "indexer-manager", - "dashdot", - "usenet", - "weather", - "torrents-status", - "dlspeed", - "date", - "rss", - "video-stream", - "iframe", - "media-server", - "media-requests-list", - "media-requests-stats", - "dns-hole-summary", - "dns-hole-controls", - "bookmark", - "notebook", - "smart-home/entity-state", - "smart-home/trigger-automation", - "health-monitoring", - "media-transcoding", -] as const; - -export type OldmarrWidgetKind = (typeof oldmarrWidgetKinds)[number]; - -export const oldmarrWidgetSchema = z - .object({ - id: z.string(), - type: z.enum(oldmarrWidgetKinds), - properties: z.record(z.string(), z.unknown()), - }) - .and(tileBaseSchema); - -export type OldmarrWidget = z.infer; diff --git a/packages/old-schema/tsconfig.json b/packages/old-schema/tsconfig.json deleted file mode 100644 index a7b28ed61..000000000 --- a/packages/old-schema/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "@homarr/tsconfig/base.json", - "compilerOptions": { - "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" - }, - "include": ["*.ts", "src", "../old-import/src/widgets/options.ts"], - "exclude": ["node_modules"] -} diff --git a/packages/old-import/package.json b/packages/onboarding/package.json similarity index 57% rename from packages/old-import/package.json rename to packages/onboarding/package.json index 14b6e3e3f..93962200d 100644 --- a/packages/old-import/package.json +++ b/packages/onboarding/package.json @@ -1,5 +1,5 @@ { - "name": "@homarr/old-import", + "name": "@homarr/onboarding", "version": "0.1.0", "private": true, "license": "Apache-2.0", @@ -12,43 +12,38 @@ } }, "exports": { - ".": "./index.ts", - "./analyse": "./src/analyse/index.ts", - "./components": "./src/components/index.ts", - "./import": "./src/import/index.ts", - "./shared": "./src/shared.ts" + ".": "./src/index.ts", + "./types": "./src/types.ts" }, "scripts": { "clean": "rm -rf .turbo node_modules", "format": "oxfmt .", "lint": "oxlint", + "test": "vitest run --root ../.. packages/onboarding/src", "typecheck": "tsc --noEmit" }, "dependencies": { + "@homarr/api": "workspace:^0.1.0", + "@homarr/auth": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0", - "@homarr/core": "workspace:^0.1.0", - "@homarr/db": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0", - "@homarr/modals": "workspace:^0.1.0", + "@homarr/forms-collection": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0", - "@homarr/old-schema": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", "@mantine/core": "catalog:", "@mantine/hooks": "catalog:", - "adm-zip": "catalog:", - "next": "catalog:", + "@tabler/icons-react": "catalog:", + "motion": "catalog:", "react": "catalog:", - "react-dom": "catalog:", - "superjson": "catalog:", - "zod": "catalog:", - "zod-form-data": "catalog:" + "zod": "catalog:" }, "devDependencies": { "@homarr/tsconfig": "workspace:^0.1.0", - "@types/adm-zip": "catalog:", - "typescript": "catalog:" + "@types/react": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/onboarding/src/account.tsx b/packages/onboarding/src/account.tsx new file mode 100644 index 000000000..77554503d --- /dev/null +++ b/packages/onboarding/src/account.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { Alert, Button, Group, Paper, Stack, Text, TextInput, ThemeIcon, Title } from "@mantine/core"; +import { IconArrowRight, IconShieldCheck, IconUserPlus } from "@tabler/icons-react"; +import type { z } from "zod/v4"; + +import { clientApi } from "@homarr/api/client"; +import { signIn } from "@homarr/auth/client"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import { useZodForm } from "@homarr/form"; +import { UserCreatePasswordFields } from "@homarr/forms-collection"; +import { useScopedI18n } from "@homarr/translation/client"; +import { groupCreateSchema } from "@homarr/validation/group"; +import { userInitSchema } from "@homarr/validation/user"; + +import type { OnboardingStudioProps } from "./types"; +import classes from "./onboarding-studio.module.css"; + +const AccountShell = ({ + title, + description, + children, +}: { + title: string; + description: string; + children: React.ReactNode; +}) => ( +
+
+ + + + + + + + + + {title} + + {description} + + + {children} + + + +
+
+); + +export const AccountSetup = ({ environment }: OnboardingStudioProps) => { + if (environment.currentStep === "user") return ; + return ; +}; + +const CredentialsSetup = () => { + const t = useScopedI18n("init.studio.account"); + const mutation = clientApi.user.initUser.useMutation(); + const form = useZodForm(userInitSchema, { + initialValues: { username: "", password: "", confirmPassword: "" }, + }); + + const submitAsync = async (values: z.infer) => { + try { + await mutation.mutateAsync(values); + const result = await signIn("credentials", { + name: values.username, + password: values.password, + redirect: false, + }); + if (result?.error) throw new Error(t("signInError")); + await revalidatePathActionAsync("/init"); + } catch (error) { + form.setErrors({ username: error instanceof Error ? error.message : t("unknownError") }); + } + }; + + return ( + +
void submitAsync(values))}> + + + + {mutation.error ? ( + + {mutation.error.message} + + ) : null} + + +
+
+ ); +}; + +const ExternalGroupSetup = () => { + const t = useScopedI18n("init.studio.externalGroup"); + const mutation = clientApi.group.createInitialExternalGroup.useMutation({ + async onSuccess() { + await revalidatePathActionAsync("/init"); + }, + }); + const form = useZodForm(groupCreateSchema, { initialValues: { name: "" } }); + + return ( + +
mutation.mutate(values))}> + + } + withAsterisk + {...form.getInputProps("name")} + /> + {mutation.error ? ( + + {mutation.error.message} + + ) : null} + + +
+
+ ); +}; diff --git a/packages/onboarding/src/finish.tsx b/packages/onboarding/src/finish.tsx new file mode 100644 index 000000000..ab327948e --- /dev/null +++ b/packages/onboarding/src/finish.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { Alert, Button, Group, Paper, SimpleGrid, Stack, Text, ThemeIcon, Title } from "@mantine/core"; +import { IconArrowRight, IconBook2, IconLayoutDashboard, IconSparkles, IconTool } from "@tabler/icons-react"; +import { motion, useReducedMotion } from "motion/react"; + +import { createDocumentationLink } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; +import { Link } from "@homarr/ui"; + +import type { OnboardingStudioProps } from "./types"; +import classes from "./onboarding-studio.module.css"; + +export const Finish = ({ environment }: OnboardingStudioProps) => { + const t = useScopedI18n("init.studio.finish"); + const reduceMotion = useReducedMotion(); + const boardHref = `/boards/${encodeURIComponent(environment.initialBoard?.name ?? "dashboard")}`; + const destination = environment.canConfigurePrivileged + ? boardHref + : `/auth/login?callbackUrl=${encodeURIComponent(boardHref)}`; + + return ( +
+
+ + + + + + + + + + + {t("title")} + + {t("description")} + + + + }> + {t("tutorial")} + + + + + + + + + + +
+
+ ); +}; + +const NextCard = (props: { + icon: typeof IconTool; + title: string; + description: string; + href: string; + external?: boolean; +}) => (props.external ? : ); + +const InternalNextCard = ({ + icon: Icon, + title, + description, + href, +}: { + icon: typeof IconTool; + title: string; + description: string; + href: string; +}) => ( + + + + + + + {title} + + + {description} + + + +); + +const ExternalNextCard = ({ icon: Icon, title, description, href }: Parameters[0]) => ( + + + + + + + {title} + + + {description} + + + +); diff --git a/packages/onboarding/src/index.ts b/packages/onboarding/src/index.ts new file mode 100644 index 000000000..f3d372527 --- /dev/null +++ b/packages/onboarding/src/index.ts @@ -0,0 +1,2 @@ +export { OnboardingStudio } from "./onboarding-studio"; +export type { OnboardingEnvironment, OnboardingStudioProps } from "./types"; diff --git a/packages/onboarding/src/onboarding-studio.module.css b/packages/onboarding/src/onboarding-studio.module.css new file mode 100644 index 000000000..d31e49680 --- /dev/null +++ b/packages/onboarding/src/onboarding-studio.module.css @@ -0,0 +1,504 @@ +.page { + --studio-surface: color-mix(in srgb, var(--mantine-color-body) 92%, transparent); + min-height: 100dvh; + overflow-x: clip; + position: relative; + background: + radial-gradient( + circle at 10% 10%, + color-mix(in srgb, var(--mantine-primary-color-filled) 14%, transparent), + transparent 34rem + ), + radial-gradient( + circle at 90% 5%, + color-mix(in srgb, var(--mantine-color-orange-6) 10%, transparent), + transparent 30rem + ), + var(--mantine-color-body); +} + +.page::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + opacity: 0.22; + background-image: radial-gradient(circle, var(--mantine-color-dimmed) 0.7px, transparent 0.7px); + background-size: 24px 24px; + mask-image: linear-gradient(to bottom, black, transparent 75%); +} + +.shell { + position: relative; + width: min(100% - 2rem, 76rem); + margin-inline: auto; + padding-block: clamp(1rem, 3vw, 2.5rem) 5rem; +} + +.topbar { + min-height: 3rem; + gap: var(--mantine-spacing-sm); +} + +.wordmark { + display: block; + width: clamp(9.5rem, 22vw, 14rem); + height: auto; + padding: 0.4rem 0.55rem; + border-radius: var(--mantine-radius-md); + background: rgba(12, 12, 14, 0.86); + filter: drop-shadow(0 0.8rem 1.6rem color-mix(in srgb, black 22%, transparent)); +} + +.hero { + min-height: calc(100dvh - 8rem); + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(18rem, 0.9fr); + align-items: center; + gap: clamp(2rem, 6vw, 6rem); +} + +.heroTitle { + font-size: clamp(2.5rem, 7vw, 5.8rem); + line-height: 0.94; + letter-spacing: -0.04em; + text-wrap: balance; + max-width: 11ch; +} + +.heroPanel, +.studio { + background: var(--studio-surface); + backdrop-filter: blur(18px); + border: 1px solid color-mix(in srgb, var(--mantine-color-default-border) 76%, transparent); + box-shadow: var(--mantine-shadow-sm); +} + +.heroPanel { + position: relative; + min-height: 27rem; + overflow: hidden; +} + +.orbit { + position: absolute; + inset: 50% auto auto 50%; + width: 18rem; + height: 18rem; + margin: -9rem; + border-radius: 50%; + border: 1px solid color-mix(in srgb, var(--mantine-primary-color-filled) 35%, transparent); +} + +.orbitItem { + position: absolute; + width: 3.25rem; + height: 3.25rem; + display: grid; + place-items: center; + border-radius: var(--mantine-radius-lg); + background: var(--mantine-color-body); + border: 1px solid var(--mantine-color-default-border); + box-shadow: var(--mantine-shadow-md); +} + +.orbitItem:nth-child(1) { + inset: -1.6rem auto auto calc(50% - 1.6rem); +} +.orbitItem:nth-child(2) { + inset: calc(50% - 1.6rem) -1.6rem auto auto; +} +.orbitItem:nth-child(3) { + inset: auto auto -1.6rem calc(50% - 1.6rem); +} +.orbitItem:nth-child(4) { + inset: calc(50% - 1.6rem) auto auto -1.6rem; +} + +.studio { + overflow: clip; +} + +.studioGrid { + display: grid; + grid-template-columns: 17rem minmax(0, 1fr); + min-height: 40rem; +} + +.rail { + padding: 1.25rem; + border-right: 1px solid var(--mantine-color-default-border); + background: color-mix(in srgb, var(--mantine-color-default) 40%, transparent); +} + +.railButton { + width: 100%; + min-height: 3.25rem; + padding: 0.65rem 0.75rem; + border-radius: var(--mantine-radius-md); + color: var(--mantine-color-dimmed); + transition: + background-color 150ms ease, + color 150ms ease, + transform 150ms ease; +} + +.railButton[data-active="true"] { + color: var(--mantine-color-text); + background: var(--mantine-color-default-hover); +} + +.railButton:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 2px; +} + +.content { + min-width: 0; + padding: clamp(1rem, 3vw, 2rem); +} + +.sectionCard { + background: var(--studio-surface); + border: 1px solid color-mix(in srgb, var(--mantine-color-default-border) 76%, transparent); +} + +.choiceGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--mantine-spacing-sm); +} + +.choice { + min-height: 9rem; + text-align: left; + border-radius: var(--mantine-radius-lg); + border: 1px solid var(--mantine-color-default-border); + background: var(--mantine-color-body); + padding: var(--mantine-spacing-md); + transition: + border-color 150ms ease, + background-color 150ms ease, + transform 150ms ease; +} + +.choice[data-selected="true"] { + border-color: var(--mantine-primary-color-filled); + background: color-mix(in srgb, var(--mantine-primary-color-light) 70%, var(--mantine-color-body)); +} + +.choice:hover { + transform: translateY(-2px); +} + +.choice:focus-visible { + outline: 3px solid var(--mantine-primary-color-light-color); + outline-offset: 2px; +} + +.preview { + margin: 0; + aspect-ratio: 16 / 9; + display: grid; + gap: 0.4rem; + padding: 0.6rem; + overflow: hidden; + border-radius: var(--mantine-radius-lg); + background: color-mix(in srgb, var(--mantine-color-default) 66%, transparent); + border: 1px solid var(--mantine-color-default-border); +} + +.previewLane { + min-width: 0; + border-radius: calc(var(--mantine-radius-lg) * 0.65); + background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent); + border: 1px solid color-mix(in srgb, var(--mantine-color-default-border) 70%, transparent); + padding: 0.35rem; +} + +.previewTiles { + height: 100%; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-auto-rows: minmax(0.8rem, 1fr); + gap: 0.3rem; +} + +.previewTile { + border-radius: calc(var(--mantine-radius-lg) * 0.45); + background: linear-gradient( + 135deg, + var(--mantine-primary-color-light), + color-mix(in srgb, var(--mantine-color-orange-light) 60%, transparent) + ); + border: 1px solid color-mix(in srgb, var(--mantine-primary-color-filled) 22%, transparent); +} + +.previewTile:nth-child(1) { + grid-column: span 2; + grid-row: span 2; +} +.previewTile:nth-child(2) { + grid-column: span 2; +} +.previewTile:nth-child(3) { + grid-column: span 1; +} +.previewTile:nth-child(4) { + grid-column: span 1; +} +.previewTile:nth-child(5) { + grid-column: span 4; +} + +.assistantStage { + position: relative; + min-height: 13rem; + overflow: hidden; + border-radius: var(--mantine-radius-lg); + background: color-mix(in srgb, var(--mantine-primary-color-light) 44%, var(--mantine-color-body)); +} + +.assistantGlow { + position: absolute; + inset: 50% auto auto 50%; + width: 12rem; + height: 12rem; + margin: -6rem; + border-radius: 50%; + background: radial-gradient( + circle, + color-mix(in srgb, var(--mantine-primary-color-filled) 35%, transparent), + transparent 68% + ); +} + +.stickyActions { + position: sticky; + z-index: 2; + bottom: 0; + margin: 1.5rem -2rem -2rem; + padding: 1rem 2rem; + background: color-mix(in srgb, var(--mantine-color-body) 92%, transparent); + border-top: 1px solid var(--mantine-color-default-border); + backdrop-filter: blur(16px); +} + +.applyError { + margin-top: var(--mantine-spacing-lg); +} + +.nextCard { + color: inherit; + text-decoration: none; + transition: + background-color 150ms ease, + transform 150ms ease; +} + +.nextCard:hover { + background: var(--mantine-color-default-hover); + transform: translateY(-2px); +} + +.nextCard:focus-visible { + outline: 3px solid var(--mantine-primary-color-light-color); + outline-offset: 2px; +} + +.content :global(.mantine-Button-root), +.content :global(.mantine-SegmentedControl-label), +.page :global(.mantine-Input-input), +.page :global(.mantine-Tabs-tab) { + min-height: 2.75rem; +} + +.page :global(.mantine-Switch-body) { + min-height: 2.75rem; + align-items: center; +} + +@media (max-width: 62em) { + .hero { + grid-template-columns: 1fr; + padding-block: 3rem; + } + + .heroPanel { + min-height: 22rem; + } + + .studioGrid { + grid-template-columns: 1fr; + } + + .rail { + border-right: 0; + border-bottom: 1px solid var(--mantine-color-default-border); + overflow-x: auto; + } + + .railList { + min-width: max-content; + flex-direction: row; + } + + .railButton { + width: auto; + min-width: 10rem; + } +} + +@media (max-width: 48em) { + .shell { + width: min(100% - 1rem, 76rem); + } + + .heroTitle { + font-size: clamp(2.35rem, 14vw, 4.5rem); + } + + .choiceGrid { + grid-template-columns: 1fr; + } + + .content { + padding: 1rem; + } + + .stickyActions { + margin: 1rem -1rem -1rem; + padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom)); + } + + .primaryActions { + width: 100%; + } + + .primaryActions > * { + flex: 1 1 12rem; + } + + .content :global(.mantine-SegmentedControl-root) { + overflow-x: auto; + } + + .content :global(.mantine-Tabs-list) { + flex-wrap: nowrap; + overflow-x: auto; + } +} + +@media (max-width: 30em) { + .shell { + width: calc(100% - 0.75rem); + padding-top: 0.75rem; + } + + .topbar { + align-items: flex-start; + flex-direction: column; + } + + .wordmark { + width: 9.5rem; + } + + .hero { + min-height: auto; + gap: 2rem; + padding-block: 2rem 3rem; + } + + .heroPanel { + min-height: 19rem; + } + + .orbit { + scale: 0.78; + } + + .rail { + padding: 0.75rem; + } + + .railButton { + min-width: 8.5rem; + } + + .stickyActions { + gap: var(--mantine-spacing-xs); + } + + .backAction { + flex: 0 0 2.75rem; + width: 2.75rem; + padding-inline: 0; + } + + .backAction :global(.mantine-Button-section) { + margin-inline: 0; + } + + .backAction :global(.mantine-Button-label) { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } + + .primaryActions { + flex: 1 1 auto; + min-width: 0; + } + + .fastAction { + flex: 1.15 1 0; + min-width: 0; + padding-inline: 0.25rem; + } + + .primaryActions > :global(.mantine-Button-root):last-child { + flex: 1 1 0; + min-width: 0; + padding-inline: 0.25rem; + } + + .primaryActions > :global(.mantine-Button-root) { + font-size: var(--mantine-font-size-sm); + } + + .content :global(.mantine-SegmentedControl-label) { + min-width: 0; + height: auto; + padding: 0.45rem 0.3rem; + line-height: 1.15; + white-space: normal; + } + + .sectionCard :global(.mantine-Group-root) { + flex-wrap: wrap; + } +} + +@media (prefers-reduced-motion: reduce) { + .page { + scroll-behavior: auto; + } + + .choice, + .railButton, + .nextCard { + transition: none; + } + + .choice:hover { + transform: none; + } + + .nextCard:hover { + transform: none; + } +} diff --git a/packages/onboarding/src/onboarding-studio.tsx b/packages/onboarding/src/onboarding-studio.tsx new file mode 100644 index 000000000..a331bbfec --- /dev/null +++ b/packages/onboarding/src/onboarding-studio.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Alert, Button, Center, Loader, Paper, Stack, Text, Title } from "@mantine/core"; + +import { useScopedI18n } from "@homarr/translation/client"; +import { Link } from "@homarr/ui"; + +import type { OnboardingStudioProps } from "./types"; +import { AccountSetup } from "./account"; +import { Finish } from "./finish"; +import { SetupStudio } from "./setup-studio"; +import { Welcome } from "./welcome"; + +export const OnboardingStudio = (props: OnboardingStudioProps) => { + const t = useScopedI18n("init.studio.claim"); + const tCommon = useScopedI18n("common.action"); + const [claimState, setClaimState] = useState<"checking" | "ready" | "locked" | "signIn" | "error">( + props.environment.currentStep === "start" || props.environment.currentStep === "finish" ? "ready" : "checking", + ); + const [claimError, setClaimError] = useState(null); + + useEffect(() => { + if (props.environment.currentStep === "start" || props.environment.currentStep === "finish") return; + if (props.environment.canConfigurePrivileged) { + setClaimState("ready"); + return; + } + if ( + props.environment.hasUsers || + (props.environment.externalAuthEnabled && props.environment.currentStep === "setup") + ) { + setClaimState("signIn"); + return; + } + + const controller = new AbortController(); + void fetch("/api/onboarding/claim", { method: "POST", signal: controller.signal }) + .then(async (response) => { + if (response.ok) { + setClaimState("ready"); + return; + } + const body = (await response.json().catch(() => null)) as { error?: string } | null; + setClaimError(body?.error ?? t("errorDescription")); + setClaimState(response.status === 423 ? "locked" : response.status === 403 ? "signIn" : "error"); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) return; + setClaimError(error instanceof Error ? error.message : t("errorDescription")); + setClaimState("error"); + }); + return () => controller.abort(); + }, [ + props.environment.canConfigurePrivileged, + props.environment.currentStep, + props.environment.externalAuthEnabled, + props.environment.hasUsers, + t, + ]); + + if (claimState !== "ready") { + const signIn = claimState === "signIn"; + return ( +
+ + + {claimState === "checking" ? : null} + + {claimState === "checking" + ? t("checkingTitle") + : signIn + ? t("signInTitle") + : claimState === "locked" + ? t("lockedTitle") + : t("errorTitle")} + + + {signIn + ? t("signInDescription") + : claimState === "locked" + ? t("lockedDescription") + : claimState === "checking" + ? t("checking") + : (claimError ?? t("errorDescription"))} + + {signIn ? ( + + ) : claimState === "locked" ? ( + + ) : claimState === "error" ? ( + + + {claimError ?? t("errorDescription")} + + + + ) : null} + + +
+ ); + } + + switch (props.environment.currentStep) { + case "start": + return ; + case "user": + case "group": + return ; + case "setup": + return ; + case "finish": + return ; + } +}; diff --git a/packages/onboarding/src/setup-studio.tsx b/packages/onboarding/src/setup-studio.tsx new file mode 100644 index 000000000..e8ff32b36 --- /dev/null +++ b/packages/onboarding/src/setup-studio.tsx @@ -0,0 +1,1745 @@ +"use client"; + +import type { CSSProperties, KeyboardEvent, ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Accordion, + ActionIcon, + Alert, + Anchor, + Avatar, + Badge, + Button, + Checkbox, + Code, + ColorInput, + CopyButton, + Divider, + Fieldset, + Group, + Image, + Loader, + Paper, + PasswordInput, + Progress, + SegmentedControl, + Select, + SimpleGrid, + Stack, + Switch, + Tabs, + Text, + TextInput, + ThemeIcon, + Title, + UnstyledButton, + useMantineColorScheme, +} from "@mantine/core"; +import type { MantineSize } from "@mantine/core"; +import { + IconAdjustments, + IconApps, + IconArrowLeft, + IconArrowRight, + IconBrandDocker, + IconBrain, + IconCheck, + IconCode, + IconCopy, + IconDatabase, + IconDeviceMobile, + IconExternalLink, + IconKey, + IconLayoutDashboard, + IconPalette, + IconPlugConnected, + IconRefresh, + IconSearch, + IconServer, + IconSparkles, + IconTool, + IconWorld, +} from "@tabler/icons-react"; +import { motion, useReducedMotion } from "motion/react"; + +import { clientApi } from "@homarr/api/client"; +import type { RouterOutputs } from "@homarr/api"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import type { + AssistantProvider, + ColorScheme, + IntegrationKind, + IntegrationSecretKind, + UrlTemplateMode, +} from "@homarr/definitions"; +import { + assistantProviderCanUseOpenRouterServerTools, + assistantProviderIds, + assistantProviderPresets, + buildAppUrl, + buildIntegrationUrl, + getAllSecretKindOptions, + getIntegrationApiKeyUrl, + getIntegrationDefaultUrl, + getIntegrationDocumentationUrl, + getIntegrationName, +} from "@homarr/definitions"; +import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications"; +import type { SupportedLanguage } from "@homarr/translation"; +import { localeConfigurations, supportedLanguages } from "@homarr/translation"; +import { useChangeLocale, useCurrentLocale, useScopedI18n } from "@homarr/translation/client"; +import { IntegrationAvatar, Link } from "@homarr/ui"; +import { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid"; + +import type { OnboardingStudioProps } from "./types"; +import classes from "./onboarding-studio.module.css"; + +type StudioSection = "essentials" | "discover" | "connect" | "board" | "extend" | "review"; +type LayoutPreset = "balanced" | "wide" | "focused"; + +interface IntegrationDraft { + id: string; + sourceId?: string; + integrationId?: string; + appId?: string; + kind: IntegrationKind; + name: string; + url: string; + source: "manual" | "docker"; + secretOption: number; + secrets: { kind: IntegrationSecretKind; value: string }[]; + saved: boolean; + error: string | null; +} + +type DockerDiscoveryData = RouterOutputs["onboard"]["discoverDockerServices"]; + +const sectionDefinitions = [ + { id: "essentials", icon: IconAdjustments }, + { id: "discover", icon: IconSearch }, + { id: "connect", icon: IconPlugConnected }, + { id: "board", icon: IconLayoutDashboard }, + { id: "extend", icon: IconSparkles }, + { id: "review", icon: IconCheck }, +] as const; + +const radiusValues = ["xs", "sm", "md", "lg", "xl"] as const; +const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = []; +const emptyDiscoveredApps: DockerDiscoveryData["apps"] = []; + +export const SetupStudio = ({ environment }: OnboardingStudioProps) => { + const t = useScopedI18n("init.studio"); + const currentLocale = useCurrentLocale(); + const { changeLocale, isPending: localePending } = useChangeLocale(); + const { colorScheme, setColorScheme } = useMantineColorScheme(); + const reduceMotion = useReducedMotion(); + const [activeSection, setActiveSection] = useState("essentials"); + const [serverOrigin, setServerOrigin] = useState(""); + const [urlMode, setUrlMode] = useState("hostPort"); + const [analyticsEnabled, setAnalyticsEnabled] = useState(true); + const [selectedKinds, setSelectedKinds] = useState([]); + const [drafts, setDrafts] = useState([]); + const [selectedAppIds, setSelectedAppIds] = useState([]); + const [createdAppIdsBySource, setCreatedAppIdsBySource] = useState>({}); + const [selectedBoardId, setSelectedBoardId] = useState(environment.initialBoard?.id ?? null); + const [boardName, setBoardName] = useState(environment.initialBoard?.name ?? "dashboard"); + const [primaryColor, setPrimaryColor] = useState("#fa5252"); + const [secondaryColor, setSecondaryColor] = useState("#fd7e14"); + const [itemRadius, setItemRadius] = useState("lg"); + const [layoutPreset, setLayoutPreset] = useState("balanced"); + const [leftSidebar, setLeftSidebar] = useState(false); + const [rightSidebar, setRightSidebar] = useState(false); + const [applyProgress, setApplyProgress] = useState(0); + const [applyMessage, setApplyMessage] = useState(""); + const [applyError, setApplyError] = useState(null); + const [appError, setAppError] = useState(null); + const sectionHeadingRef = useRef(null); + const initialSection = useRef(true); + const focusSectionHeading = useRef(true); + + const docker = clientApi.onboard.discoverDockerServices.useQuery(undefined, { + enabled: environment.dockerConfigured, + retry: false, + refetchOnWindowFocus: false, + }); + const createIntegration = clientApi.onboard.createIntegration.useMutation(); + const createApps = clientApi.onboard.createAppsFromDiscovery.useMutation(); + const complete = clientApi.onboard.completeSetup.useMutation(); + const isApplying = complete.isPending || createIntegration.isPending || createApps.isPending; + + const dockerData = docker.data; + const discoveredIntegrations = dockerData?.integrations ?? emptyDiscoveredIntegrations; + const discoveredApps = dockerData?.apps ?? emptyDiscoveredApps; + const detectedKinds = useMemo( + () => new Set(discoveredIntegrations.map((integration) => integration.kind)), + [discoveredIntegrations], + ); + + useEffect(() => { + if (!docker.data) return; + setSelectedKinds((current) => [...new Set([...current, ...discoveredIntegrations.map((item) => item.kind)])]); + setSelectedAppIds((current) => + current.length > 0 ? current : discoveredApps.map((application) => application.sourceId), + ); + }, [docker.data, discoveredApps, discoveredIntegrations]); + + useEffect(() => { + setDrafts((current) => { + const next: IntegrationDraft[] = []; + const existing = new Map(current.map((draft) => [draft.id, draft])); + for (const discovered of discoveredIntegrations) { + if (!selectedKinds.includes(discovered.kind)) continue; + const id = discovered.sourceId; + const generatedUrl = + discovered.suggestedUrl || + (serverOrigin + ? buildIntegrationUrl(discovered.kind, serverOrigin, urlMode, discovered.publishedPort ?? undefined) + : ""); + const existingDraft = existing.get(id); + next.push( + existingDraft + ? existingDraft.url || !generatedUrl + ? existingDraft + : { ...existingDraft, url: generatedUrl } + : createDraft({ + id, + sourceId: discovered.sourceId, + kind: discovered.kind, + name: discovered.containerName, + url: generatedUrl, + source: "docker", + }), + ); + } + for (const kind of selectedKinds) { + if (discoveredIntegrations.some((item) => item.kind === kind)) continue; + const id = `manual:${kind}`; + const generatedUrl = serverOrigin + ? buildIntegrationUrl(kind, serverOrigin, urlMode) + : getIntegrationDefaultUrl(kind); + const existingDraft = existing.get(id); + next.push( + existingDraft + ? existingDraft.url || !generatedUrl + ? existingDraft + : { ...existingDraft, url: generatedUrl } + : createDraft({ + id, + sourceId: id, + kind, + name: getIntegrationName(kind), + url: generatedUrl ?? "", + source: "manual", + }), + ); + } + return next; + }); + }, [discoveredIntegrations, selectedKinds, serverOrigin, urlMode]); + + useEffect(() => { + if (initialSection.current) { + initialSection.current = false; + return; + } + if (!focusSectionHeading.current) { + focusSectionHeading.current = true; + return; + } + sectionHeadingRef.current?.focus(); + }, [activeSection]); + + const activeIndex = sectionDefinitions.findIndex((section) => section.id === activeSection); + const move = (direction: -1 | 1) => { + const next = sectionDefinitions[activeIndex + direction]; + if (next) selectSection(next.id); + }; + + const selectSection = (section: StudioSection) => { + setApplyError(null); + setActiveSection(section); + }; + + const handleSectionKeyDown = (event: KeyboardEvent, index: number) => { + const nextIndex = + event.key === "Home" + ? 0 + : event.key === "End" + ? sectionDefinitions.length - 1 + : event.key === "ArrowRight" || event.key === "ArrowDown" + ? (index + 1) % sectionDefinitions.length + : event.key === "ArrowLeft" || event.key === "ArrowUp" + ? (index - 1 + sectionDefinitions.length) % sectionDefinitions.length + : null; + if (nextIndex === null) return; + event.preventDefault(); + const nextSection = sectionDefinitions[nextIndex]; + if (!nextSection) return; + focusSectionHeading.current = false; + selectSection(nextSection.id); + const nextButton = event.currentTarget.parentElement?.querySelectorAll("button").item(nextIndex); + if (nextButton instanceof HTMLButtonElement) nextButton.focus(); + }; + + const updateDraft = (id: string, patch: Partial) => { + setDrafts((current) => + current.map((draft) => (draft.id === id ? { ...draft, ...patch, saved: patch.saved ?? false } : draft)), + ); + }; + + const selectSecretOption = (draft: IntegrationDraft, option: number) => { + const secretKinds = getAllSecretKindOptions(draft.kind)[option] ?? []; + updateDraft(draft.id, { + secretOption: option, + secrets: secretKinds.map((kind) => ({ + kind, + value: draft.secrets.find((secret) => secret.kind === kind)?.value ?? "", + })), + }); + }; + + const applySetupAsync = async (includeSelections = true) => { + setApplyError(null); + setAppError(null); + setApplyProgress(5); + try { + if (!selectedBoardId && environment.availableBoards.length > 0) { + setActiveSection("board"); + throw new Error(t("board.targetRequired")); + } + if (!boardName.trim()) { + setActiveSection("board"); + throw new Error(t("board.nameDescription")); + } + const draftsToApply = includeSelections ? drafts : []; + const invalidDraft = draftsToApply.find( + (draft) => !draft.url.trim() || draft.secrets.some((secret) => secret.value.trim().length === 0), + ); + if (invalidDraft) { + const message = t("connect.validationError", { name: invalidDraft.name }); + updateDraft(invalidDraft.id, { error: message }); + setActiveSection("connect"); + throw new Error(message); + } + + const unsaved = draftsToApply.filter((draft) => !draft.saved); + const selectedIntegrationIds = draftsToApply.flatMap((draft) => + draft.integrationId ? [draft.integrationId] : [], + ); + const integrationAppIds = draftsToApply.flatMap((draft) => (draft.appId ? [draft.appId] : [])); + for (const [index, draft] of unsaved.entries()) { + setApplyMessage(t("review.progress.integration", { name: draft.name })); + setApplyProgress(10 + Math.round(((index + 1) / Math.max(unsaved.length, 1)) * 45)); + try { + const discovered = draft.sourceId + ? discoveredIntegrations.find((integration) => integration.sourceId === draft.sourceId) + : undefined; + const result = await createIntegration.mutateAsync({ + name: draft.name, + url: draft.url, + kind: draft.kind, + secrets: draft.secrets, + sourceId: draft.sourceId, + iconUrl: discovered?.iconUrl ?? null, + description: discovered?.description ?? null, + pingUrl: discovered?.pingUrl ?? null, + }); + if (result && "error" in result && result.error) { + throw new Error(t("connect.connectionError", { name: draft.name })); + } + selectedIntegrationIds.push(result.id); + integrationAppIds.push(result.appId); + updateDraft(draft.id, { saved: true, error: null, integrationId: result.id, appId: result.appId }); + } catch (error) { + const message = error instanceof Error ? error.message : t("common.unknownError"); + updateDraft(draft.id, { error: message }); + throw error; + } + } + + const appsToCreate = includeSelections + ? discoveredApps.filter((app) => selectedAppIds.includes(app.sourceId)) + : []; + let createdAppIds = appsToCreate.flatMap((app) => { + const appId = createdAppIdsBySource[app.sourceId]; + return appId ? [appId] : []; + }); + if (appsToCreate.length > 0) { + setApplyMessage(t("review.progress.apps")); + setApplyProgress(68); + try { + const result = await createApps.mutateAsync( + appsToCreate.map((app) => ({ + sourceId: app.sourceId, + name: app.containerName, + href: + app.suggestedUrl || + (serverOrigin + ? buildAppUrl(app.containerName, serverOrigin, urlMode, app.publishedPort ?? undefined) + : null), + pingUrl: app.pingUrl ?? null, + iconUrl: app.iconUrl, + description: app.description ?? null, + })), + ); + const createdApps = Object.fromEntries( + result.apps.flatMap((app) => (app.sourceId ? [[app.sourceId, app.id]] : [])), + ); + setCreatedAppIdsBySource((current) => ({ ...current, ...createdApps })); + createdAppIds = result.apps.map((app) => app.id); + } catch (error) { + setAppError(error instanceof Error ? error.message : t("common.unknownError")); + setActiveSection("connect"); + throw error; + } + } + + setApplyMessage(t("review.progress.board")); + setApplyProgress(84); + const selectedSourceIds = new Set(draftsToApply.flatMap((draft) => (draft.sourceId ? [draft.sourceId] : []))); + const completion = await complete.mutateAsync({ + server: { + defaultLocale: currentLocale, + defaultColorScheme: colorScheme as ColorScheme, + analyticsEnabled, + }, + board: { + id: selectedBoardId ?? undefined, + name: boardName, + primaryColor, + secondaryColor, + itemRadius, + layoutPreset, + leftSidebar, + rightSidebar, + }, + selectedIntegrationIds, + selectedAppIds: [...new Set([...integrationAppIds, ...createdAppIds])], + selectedDockerSourceIds: [ + ...new Set([ + ...discoveredIntegrations + .filter((integration) => integration.source === "label" && selectedSourceIds.has(integration.sourceId)) + .map((integration) => integration.sourceId), + ...appsToCreate + .filter((application) => application.source === "label") + .map((application) => application.sourceId), + ]), + ], + selectedWidgetKinds: [ + ...new Set( + [ + ...discoveredIntegrations.filter((integration) => selectedSourceIds.has(integration.sourceId)), + ...appsToCreate, + ].flatMap((discovery) => (discovery.widgetKind ? [discovery.widgetKind] : [])), + ), + ], + }); + const dockerWarningCount = + completion.docker.missingSourceIds.length + + completion.docker.ignoredSourceIds.length + + completion.docker.skippedWidgets.length; + if (dockerWarningCount > 0) { + showWarningNotification({ + title: t("review.dockerWarningTitle"), + message: t("review.dockerWarningDescription", { count: String(dockerWarningCount) }), + }); + } + setApplyMessage(t("review.progress.done")); + setApplyProgress(100); + await revalidatePathActionAsync("/init"); + } catch (error) { + setApplyError(error instanceof Error ? error.message : t("common.unknownError")); + setApplyProgress(0); + } + }; + + const sectionProps: StudioSectionProps = { + headingRef: sectionHeadingRef, + environment, + serverOrigin, + setServerOrigin, + urlMode, + setUrlMode, + analyticsEnabled, + setAnalyticsEnabled, + currentLocale, + changeLocale, + localePending, + colorScheme: colorScheme as ColorScheme, + setColorScheme, + docker, + dockerData, + selectedKinds, + setSelectedKinds, + detectedKinds, + drafts, + updateDraft, + selectSecretOption, + discoveredApps, + selectedAppIds, + setSelectedAppIds, + boardName, + setBoardName, + selectedBoardId, + setSelectedBoardId, + primaryColor, + setPrimaryColor, + secondaryColor, + setSecondaryColor, + itemRadius, + setItemRadius, + layoutPreset, + setLayoutPreset, + leftSidebar, + setLeftSidebar, + rightSidebar, + setRightSidebar, + applyProgress, + applyMessage, + applyError, + appError, + }; + + return ( +
+
+ + Homarr + + {t("setupBadge")} + + {environment.databaseDriver} + + + + + +
+ + +
+ + + + + {applyError ? ( + + {applyError} + + ) : null} + + + + {activeSection === "review" ? ( + + ) : ( + + {activeSection === "essentials" ? ( + + ) : null} + + + )} + +
+
+
+
+
+ ); +}; + +interface StudioSectionProps { + headingRef: React.RefObject; + environment: OnboardingStudioProps["environment"]; + serverOrigin: string; + setServerOrigin: (value: string) => void; + urlMode: UrlTemplateMode; + setUrlMode: (value: UrlTemplateMode) => void; + analyticsEnabled: boolean; + setAnalyticsEnabled: (value: boolean) => void; + currentLocale: SupportedLanguage; + changeLocale: (value: SupportedLanguage) => void; + localePending: boolean; + colorScheme: ColorScheme; + setColorScheme: (value: ColorScheme) => void; + docker: ReturnType; + dockerData: DockerDiscoveryData | undefined; + selectedKinds: IntegrationKind[]; + setSelectedKinds: (value: IntegrationKind[]) => void; + detectedKinds: Set; + drafts: IntegrationDraft[]; + updateDraft: (id: string, patch: Partial) => void; + selectSecretOption: (draft: IntegrationDraft, option: number) => void; + discoveredApps: DockerDiscoveryData["apps"]; + selectedAppIds: string[]; + setSelectedAppIds: (value: string[]) => void; + boardName: string; + setBoardName: (value: string) => void; + selectedBoardId: string | null; + setSelectedBoardId: (value: string | null) => void; + primaryColor: string; + setPrimaryColor: (value: string) => void; + secondaryColor: string; + setSecondaryColor: (value: string) => void; + itemRadius: MantineSize; + setItemRadius: (value: MantineSize) => void; + layoutPreset: LayoutPreset; + setLayoutPreset: (value: LayoutPreset) => void; + leftSidebar: boolean; + setLeftSidebar: (value: boolean) => void; + rightSidebar: boolean; + setRightSidebar: (value: boolean) => void; + applyProgress: number; + applyMessage: string; + applyError: string | null; + appError: string | null; +} + +const StudioSectionContent = ({ section, ...props }: StudioSectionProps & { section: StudioSection }) => { + switch (section) { + case "essentials": + return ; + case "discover": + return ; + case "connect": + return ; + case "board": + return ; + case "extend": + return ; + case "review": + return ; + } +}; + +const SectionHeading = ({ + eyebrow, + title, + description, + headingRef, +}: { + eyebrow: string; + title: string; + description: string; + headingRef: React.RefObject; +}) => ( + + + {eyebrow} + + + {title} + + + {description} + + +); + +const Essentials = (props: StudioSectionProps) => { + const t = useScopedI18n("init.studio.essentials"); + return ( + + + + ({ value: board.id, label: board.name }))} + value={props.selectedBoardId} + onChange={(value) => { + props.setSelectedBoardId(value); + const board = props.environment.availableBoards.find((candidate) => candidate.id === value); + if (board) props.setBoardName(board.name); + }} + allowDeselect={false} + searchable + withAsterisk + error={props.selectedBoardId ? undefined : t("targetRequired")} + /> + ) : null} + props.setBoardName(event.currentTarget.value.replace(/[^A-Za-z0-9-_]/g, ""))} + withAsterisk + /> + + + + + + + {t("radius")} + + props.setItemRadius(value as MantineSize)} + data={radiusValues.map((value) => ({ value, label: value.toUpperCase() }))} + /> + + {radiusValues.map((radius, index) => ( + + ))} + + + + + + props.setLayoutPreset(value as LayoutPreset)} + data={[ + { value: "focused", label: t("layoutFocused") }, + { value: "balanced", label: t("layoutBalanced") }, + { value: "wide", label: t("layoutWide") }, + ]} + /> + + + props.setLeftSidebar(event.currentTarget.checked)} + label={t("leftSidebar")} + /> + props.setRightSidebar(event.currentTarget.checked)} + label={t("rightSidebar")} + /> + + + + + + + + + + + + ); +}; + +const BoardPreview = ({ + ariaLabel, + primaryColor, + secondaryColor, + itemRadius, + leftSidebar, + rightSidebar, +}: { + ariaLabel: string; + primaryColor: string; + secondaryColor: string; + itemRadius: MantineSize; + leftSidebar: boolean; + rightSidebar: boolean; +}) => { + const reduceMotion = useReducedMotion(); + return ( +
+ {leftSidebar ?
: null} +
+
+ {Array.from({ length: 5 }, (_, index) => ( + + ))} +
+
+ {rightSidebar ?
: null} +
+ ); +}; + +const PrimerCard = ({ + icon: Icon, + title, + description, +}: { + icon: typeof IconApps; + title: string; + description: string; +}) => ( + + + + + + + {title} + + {description} + + + + +); + +const Extensions = (props: StudioSectionProps) => { + const t = useScopedI18n("init.studio.extend"); + return ( + + + + + }> + {t("assistant")} + + }> + {t("workshop")} + + }> + {t("mcp")} + + + + + + + + + + + + + + ); +}; + +const AssistantSetup = ({ environment }: { environment: StudioSectionProps["environment"] }) => { + const t = useScopedI18n("init.studio.extend.assistantSetup"); + const reduceMotion = useReducedMotion(); + const [provider, setProvider] = useState("homarr"); + const [baseUrl, setBaseUrl] = useState(assistantProviderPresets.homarr.baseUrl); + const [apiKey, setApiKey] = useState(""); + const [modelId, setModelId] = useState(""); + const [enabled, setEnabled] = useState(true); + const [webSearch, setWebSearch] = useState(false); + const configuration = clientApi.assistant.getAdminConfiguration.useQuery(undefined, { + enabled: environment.canConfigurePrivileged, + retry: false, + }); + const models = clientApi.assistant.discoverModels.useQuery(undefined, { + enabled: false, + retry: false, + }); + const updateConnection = clientApi.assistant.updateConnection.useMutation({ + async onSuccess() { + await configuration.refetch(); + await models.refetch(); + }, + }); + const updateConfiguration = clientApi.assistant.updateConfiguration.useMutation({ + onSuccess() { + showSuccessNotification({ title: t("savedTitle"), message: t("savedDescription") }); + }, + onError(error) { + showErrorNotification({ title: t("errorTitle"), message: error.message }); + }, + }); + + const preset = assistantProviderPresets[provider]; + const selectProvider = (value: string | null) => { + if (!value || !assistantProviderIds.includes(value as AssistantProvider)) return; + const next = value as AssistantProvider; + setProvider(next); + setBaseUrl(next === "homarr" ? environment.workshopApiUrl : assistantProviderPresets[next].baseUrl); + setModelId(""); + }; + + if (!environment.canConfigurePrivileged) { + return ( + } title={t("loginTitle")}> + + {t("loginDescription")} + + + + ); + } + + return ( + +
+ + + + + + + + {t("title")} + + {t("description")} + + +
+ + ({ value: model.id, label: model.name }))} + value={modelId} + onChange={(value) => setModelId(value ?? "")} + placeholder={models.isFetching ? t("discovering") : t("modelPlaceholder")} + disabled={models.isFetching} + /> + + setEnabled(event.currentTarget.checked)} label={t("enable")} /> + setWebSearch(event.currentTarget.checked)} + label={t("webSearch")} + disabled={!assistantProviderCanUseOpenRouterServerTools(provider)} + /> + + + + {t("advanced")} + + + +
+ ); +}; + +const WorkshopSetup = ({ environment }: { environment: StudioSectionProps["environment"] }) => { + const t = useScopedI18n("init.studio.extend.workshopSetup"); + return ( + + + + + + + + + + {t("title")} + + + {environment.workshopEnabled ? t("available") : t("unavailable")} + + + {t("description")} + + + + + + + + + + + + + ); +}; + +const WorkshopFeature = ({ title, description }: { title: string; description: string }) => ( + + + {title} + + + {description} + + +); + +const McpSetup = ({ environment }: { environment: StudioSectionProps["environment"] }) => { + const t = useScopedI18n("init.studio.extend.mcpSetup"); + const [apiKey, setApiKey] = useState(null); + const createKey = clientApi.apiKeys.create.useMutation({ + onSuccess(data) { + setApiKey(data.apiKey); + }, + }); + const config = JSON.stringify( + { + mcpServers: { + homarr: { + url: environment.mcpEndpoint, + headers: { ApiKey: apiKey ?? "" }, + }, + }, + }, + null, + 2, + ); + return ( + + } title={t("title")}> + {t("description")} + + + {environment.canConfigurePrivileged ? ( + apiKey ? ( + } title={t("keyTitle")}> + + {t("keyDescription")} + + {apiKey} + + {({ copied, copy }) => ( + + {copied ? : } + + )} + + + + + ) : ( + + + {createKey.error ? {createKey.error.message} : null} + + ) + ) : ( + {t("loginRequired")} + )} + + + + {t("configuration")} + + + {({ copied, copy }) => ( + + )} + + + {config} + + + {t("advanced")} + + + ); +}; + +const Review = (props: StudioSectionProps) => { + const t = useScopedI18n("init.studio.review"); + const reduceMotion = useReducedMotion(); + const summary = [ + { + icon: IconPalette, + label: t("appearance"), + value: `${props.currentLocale} · ${props.colorScheme} · ${props.itemRadius}`, + }, + { + icon: IconBrandDocker, + label: t("services"), + value: t("servicesValue", { + integrations: String(props.drafts.length), + apps: String(props.selectedAppIds.length), + }), + }, + { icon: IconLayoutDashboard, label: t("board"), value: props.boardName }, + { icon: IconDeviceMobile, label: t("layouts"), value: t("layoutsValue") }, + ]; + return ( + + + + {summary.map(({ icon: Icon, label, value }) => ( + + + + + + + + {label} + + + {value} + + + + + ))} + + + } title={t("automaticTitle")}> + {t("automaticDescription")} + + {props.applyProgress > 0 ? ( + + + + + {props.applyMessage} + + + {props.applyProgress}% + + + + + + ) : null} + + ); +}; + +const createDraft = ({ + id, + sourceId, + kind, + name, + url, + source, +}: Pick & { + sourceId?: string; +}): IntegrationDraft => ({ + id, + sourceId, + kind, + name, + url, + source, + secretOption: 0, + secrets: getAllSecretKindOptions(kind)[0].map((secretKind) => ({ kind: secretKind, value: "" })), + saved: false, + error: null, +}); diff --git a/packages/onboarding/src/types.ts b/packages/onboarding/src/types.ts new file mode 100644 index 000000000..543128eb6 --- /dev/null +++ b/packages/onboarding/src/types.ts @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; + +import type { OnboardingStep } from "@homarr/definitions"; + +export interface OnboardingEnvironment { + currentStep: OnboardingStep; + databaseDriver: "sqlite" | "mysql" | "postgresql"; + credentialsEnabled: boolean; + externalAuthEnabled: boolean; + dockerConfigured: boolean; + kubernetesConfigured: boolean; + workshopEnabled: boolean; + workshopApiUrl: string; + workshopUrl: string; + mcpEndpoint: string; + canConfigurePrivileged: boolean; + hasUsers: boolean; + initialBoard: { id: string; name: string } | null; + availableBoards: { id: string; name: string }[]; +} + +export interface OnboardingStudioProps { + environment: OnboardingEnvironment; + sqliteRestore?: ReactNode; +} diff --git a/packages/onboarding/src/welcome.tsx b/packages/onboarding/src/welcome.tsx new file mode 100644 index 000000000..3fc142702 --- /dev/null +++ b/packages/onboarding/src/welcome.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useState } from "react"; +import { Alert, Badge, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core"; +import { IconArrowRight, IconDatabaseImport, IconLayoutDashboard, IconSparkles } from "@tabler/icons-react"; +import { motion, useReducedMotion } from "motion/react"; + +import { clientApi } from "@homarr/api/client"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { OnboardingStudioProps } from "./types"; +import classes from "./onboarding-studio.module.css"; + +export const Welcome = ({ environment, sqliteRestore }: OnboardingStudioProps) => { + const t = useScopedI18n("init.studio.welcome"); + const reduceMotion = useReducedMotion(); + const [showRestore, setShowRestore] = useState(false); + const [claimPending, setClaimPending] = useState(false); + const [claimError, setClaimError] = useState(null); + const start = clientApi.onboard.nextStep.useMutation({ + async onSuccess() { + await revalidatePathActionAsync("/init"); + }, + }); + + const claimOnboardingAsync = async () => { + setClaimPending(true); + setClaimError(null); + try { + const response = await fetch("/api/onboarding/claim", { method: "POST" }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { error?: string } | null; + throw new Error(body?.error ?? t("errorTitle")); + } + return true; + } catch (error) { + setClaimError(error instanceof Error ? error.message : t("errorTitle")); + return false; + } finally { + setClaimPending(false); + } + }; + + const startOnboardingAsync = async () => { + if (await claimOnboardingAsync()) await start.mutateAsync(); + }; + + const toggleRestoreAsync = async () => { + if (showRestore) { + setShowRestore(false); + return; + } + if (await claimOnboardingAsync()) setShowRestore(true); + }; + + return ( +
+
+ + + + {t("badge")} + + + +
+ + + + {t("eyebrow")} + + + {t("title")} + + + {t("description")} + + + + + + {environment.databaseDriver === "sqlite" && sqliteRestore ? ( + + ) : null} + + + {showRestore && sqliteRestore ? ( + + + + + + + {t("restoreTitle")} + + + {t("restoreDescription")} + + {sqliteRestore} + + + ) : null} + + {claimError || start.error ? ( + + {claimError ?? start.error?.message} + + ) : null} + + + + +
+ {[IconLayoutDashboard, IconSparkles, IconDatabaseImport, IconLayoutDashboard].map((Icon, index) => ( + + + + ))} +
+ + + {t("ready")} + + + + + + + {t("panelTitle")} + + + {t("panelDescription")} + + + + {t("chipDiscover")} + {t("chipConnect")} + {t("chipBuild")} + + +
+
+
+
+
+ ); +}; diff --git a/packages/onboarding/tsconfig.json b/packages/onboarding/tsconfig.json new file mode 100644 index 000000000..479a72564 --- /dev/null +++ b/packages/onboarding/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@homarr/tsconfig/base.json", + "compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/spotlight/src/modes/command/global-group.tsx b/packages/spotlight/src/modes/command/global-group.tsx index fd277b55e..87a127280 100644 --- a/packages/spotlight/src/modes/command/global-group.tsx +++ b/packages/spotlight/src/modes/command/global-group.tsx @@ -2,7 +2,6 @@ import { Group, Text } from "@mantine/core"; import { IconBox, IconCategoryPlus, - IconFileImport, IconMailForward, IconPlug, IconUserPlus, @@ -11,7 +10,7 @@ import { import { useSession } from "@homarr/auth/client"; import { useModalAction } from "@homarr/modals"; -import { AddBoardModal, AddGroupModal, ImportBoardModal, InviteCreateModal } from "@homarr/modals-collection"; +import { AddBoardModal, AddGroupModal, InviteCreateModal } from "@homarr/modals-collection"; import { useScopedI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; @@ -66,29 +65,12 @@ export const globalCommandGroup = createGroup({ }, hidden: !session?.user.permissions.includes("board-create"), }, - { - commandKey: "importBoard", - icon: IconFileImport, - name: tOption("importBoard.label"), - useInteraction() { - const { openModal } = useModalAction(ImportBoardModal); - - return { - type: "javaScript", - onSelect() { - openModal(undefined); - }, - }; - }, - hidden: !session?.user.permissions.includes("board-create"), - }, { commandKey: "newApp", icon: IconBox, name: tOption("newApp.label"), useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })), - hidden: - !session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create"), + hidden: !session?.user.permissions.includes("app-create"), }, { commandKey: "newIntegration", diff --git a/packages/spotlight/src/modes/page/pages-search-group.tsx b/packages/spotlight/src/modes/page/pages-search-group.tsx index 66fe1ce4f..4db6681a3 100644 --- a/packages/spotlight/src/modes/page/pages-search-group.tsx +++ b/packages/spotlight/src/modes/page/pages-search-group.tsx @@ -76,7 +76,7 @@ export const pagesSearchGroup = createGroup<{ icon: IconBox, path: "/manage/apps", name: t("manageApp.label"), - hidden: !session?.user.permissions.includes("board-modify-all"), + hidden: !session?.user.permissions.includes("app-create"), }, { icon: IconPlug, diff --git a/packages/translation/src/lang/ca.json b/packages/translation/src/lang/ca.json index f9f68b4c4..bbd150bd3 100644 --- a/packages/translation/src/lang/ca.json +++ b/packages/translation/src/lang/ca.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Us donem la benvinguda a Homarr", - "subtitle": "Comencem configurant la vostra instància de Homarr.", - "description": "Per començar, seleccioneu com voleu configurar la vostra instància de Homarr.", - "action": { - "scratch": "Comença des de zero", - "importOldmarr": "Importa des d'una versió de Homarr anterior a 1.0", - "restoreSqlite": "", - "restoreSqliteDescription": "", - "backToOptions": "" - } - }, - "import": { - "title": "Importa dades", - "subtitle": "Pot importar dades des d'una instància de Homarr existent.", - "dropzone": { - "title": "Arrossegueu el fitxer zip aquí o feu clic per navegar", - "description": "El fitxer zip carregat es processarà i podreu seleccionar què voleu importar" - }, - "fileInfo": { - "action": { - "change": "Canvia el fitzer" - } - }, - "importSettings": { - "title": "Importa la configuració", - "description": "Configura el comportament d'importació" - }, - "boardSelection": { - "title": "S'han trobat {count} taulers", - "description": "Escolliu tots els taulers amb la mida que voleu importar", - "action": { - "selectAll": "Seleccionar-ho tot", - "unselectAll": "Anul·la la selecció" - } - }, - "summary": { - "title": "Resum de la importació", - "description": "En el resum següent podeu veure el que s'importarà", - "action": { - "import": "Confirma la importació i continua" - }, - "entities": { - "apps": "Aplicacions", - "boards": "Tauler", - "integrations": "Integracions", - "credentialUsers": "Credencials d'usuari" - } - }, - "tokenModal": { - "title": "Introduïu el token d'importació", - "field": { - "token": { - "label": "Token", - "description": "Introduïu el token d'importació mostrat a la instància prèvia de Homarr" - } - }, - "notification": { - "error": { - "title": "Token invàlid", - "message": "El token que heu introduït no és vàlid" - } - } - } - }, - "user": { - "title": "Usuari administrador", - "subtitle": "Especifiqueu les credencials del vostre usuari administrador.", - "notification": { - "success": { - "title": "S'ha creat l'usuari", - "message": "L'usuari s'ha creat correctament" - }, - "error": { - "title": "S'ha produït un error al crear l'usuari" - } - } - }, - "group": { - "title": "Grup extern", - "subtitle": "Especifiqueu el grup que s'ha d'utilitzar per als usuaris externs.", - "form": { - "name": { - "label": "Nom del grup", - "description": "El nom ha de coincidir amb el grup d'Administradors del proveïdor extern" - } - } - }, - "settings": { - "title": "Configuració", - "subtitle": "Configureu els paràmetres del servidor." - }, - "integrations": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "continue": "", - "skip": "" - }, - "configure": { - "progress": "", - "skip": "", - "finishing": "", - "error": { - "title": "", - "message": "" - }, - "helpers": { - "tooltip": "", - "description": "" - } - }, - "urlTemplate": { - "label": "", - "placeholder": "", - "hint": "", - "warning": "", - "preview": "", - "mode": { - "subdomain": "", - "hostPort": "" - } - }, - "docker": { - "detected": "", - "detectedBreakdown": "", - "summary": "", - "appBadge": "", - "appSectionLabel": "", - "scanning": "", - "scanningDescription": "" - } - }, - "finish": { - "title": "Finalitza la configuració", - "subtitle": "Ja està tot llest!", - "description": "El procés de configuració s'ha completat correctament. Podeu començar a utilitzar Homarr. Escolliu la vostra propera acció:", - "action": { - "goToBoard": "Ves al tauler {name}", - "createBoard": "Creeu el vostre primer tauler", - "inviteUser": "Convideu altres usuaris", - "docs": "Llegiu la documentació" - } - } - }, - "backToStart": "Torna a l'inici" - }, + "init": {}, "user": { "title": "Usuaris", "name": "Usuari", @@ -3989,62 +3840,6 @@ "message": "Teniu canvis sense desar, esteu segur que voleu sortir?" } }, - "oldImport": { - "label": "Importa des d'Homarr anterior a la versió 1.0.0", - "notification": { - "success": { - "title": "Importació correcta", - "message": "El tauler s'ha importat correctament" - }, - "error": { - "title": "Importació fallida", - "message": "No s'ha pogut importar el tauler, comproveu els registres per a més detalls" - } - }, - "form": { - "file": { - "label": "Selecciona un fitxer JSON", - "invalidError": "Fitxer de configuració no vàlid" - }, - "apps": { - "label": "Aplicacions", - "avoidDuplicates": { - "label": "Evita duplicats", - "description": "Ignora les aplicacions on ja existeix una aplicació amb el mateix href" - }, - "onlyImportApps": { - "label": "Importa només les aplicacions", - "description": "Només afegeix les aplicacions, el tauler s'ha de tornar a crear manualment" - } - }, - "name": { - "label": "Nom del tauler" - }, - "screenSize": { - "label": "Mida de la pantalla", - "description": "En versions anteriors a la 1.0 existien tres modes diferents, de manera que podíeu triar la quantitat de columnes per a cada mida de pantalla.", - "option": { - "sm": "Petita", - "md": "Mitjana", - "lg": "Gran" - } - }, - "sidebarBehavior": { - "label": "Comportament de la barra lateral", - "description": "Les barres laterals es van eliminar a la versió 1.0, podeu seleccionar què ha de passar amb els elements que contenen.", - "option": { - "lastSection": { - "label": "Última secció", - "description": "La barra lateral es mostrarà sota l'última secció" - }, - "removeItems": { - "label": "Elimina els elements", - "description": "Els elements continguts a la barra lateral s'eliminaran" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Crear aplicació al vol", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "Crea un tauler nou" }, - "importBoard": { - "label": "Importa un tauler" - }, "newApp": { "label": "Crea una aplicació nova" }, diff --git a/packages/translation/src/lang/cn.json b/packages/translation/src/lang/cn.json index 003b6e648..d3316400b 100644 --- a/packages/translation/src/lang/cn.json +++ b/packages/translation/src/lang/cn.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "欢迎使用 Homarr", - "subtitle": "让我们开始设置您的 Homarr 实例。", - "description": "首先,请选择您想要如何设置您的 Homarr 实例。", - "action": { - "scratch": "从零开始", - "importOldmarr": "从 1.0 之前的 Homarr 导入", - "restoreSqlite": "从 SQLite 备份恢复", - "restoreSqliteDescription": "上传之前 Homarr 实例的完整 SQLite 数据库备份。这将恢复所有面板、用户、应用、集成和设置。", - "backToOptions": "← 返回设置选项" - } - }, - "import": { - "title": "导入数据", - "subtitle": "您可以从已有的 Homarr 实例导入数据。", - "dropzone": { - "title": "将 zip 文件拖到此处或单击浏览", - "description": "上传的压缩包将被处理,您可以选择要导入的内容" - }, - "fileInfo": { - "action": { - "change": "更改文件" - } - }, - "importSettings": { - "title": "导入设置", - "description": "配置导入方式" - }, - "boardSelection": { - "title": "找到 {count} 个面板", - "description": "选择您想要导入的所有面板", - "action": { - "selectAll": "选择全部", - "unselectAll": "取消全选" - } - }, - "summary": { - "title": "导入概况", - "description": "在下面的概况中,您可以看到将要导入的内容", - "action": { - "import": "确认导入并继续" - }, - "entities": { - "apps": "应用", - "boards": "面板", - "integrations": "集成", - "credentialUsers": "用户凭证" - } - }, - "tokenModal": { - "title": "输入导入密钥", - "field": { - "token": { - "label": "秘钥", - "description": "输入您之前的 homarr 实例中显示的导入秘钥" - } - }, - "notification": { - "error": { - "title": "无效秘钥", - "message": "您输入的秘钥无效" - } - } - } - }, - "user": { - "title": "管理员用户", - "subtitle": "指定您的管理员用户的凭证。", - "notification": { - "success": { - "title": "创建用户", - "message": "用户已成功创建" - }, - "error": { - "title": "用户创建失败" - } - } - }, - "group": { - "title": "外部用户组", - "subtitle": "指定应该用于外部用户的用户组。", - "form": { - "name": { - "label": "用户组名称", - "description": "名称必须与外部提供商的管理组匹配" - } - } - }, - "settings": { - "title": "设置", - "subtitle": "配置服务器的设置" - }, - "integrations": { - "title": "集成", - "subtitle": "选择您使用的服务。", - "description": "选择您要连接的集成。我们将自动为您设置带有匹配小部件的面板。", - "action": { - "continue": "继续", - "skip": "暂时跳过" - }, - "configure": { - "progress": "{current} / {total}", - "skip": "跳过此集成", - "finishing": "正在设置您的面板……", - "error": { - "title": "设置失败", - "message": "设置面板时出现问题。请重试。" - }, - "helpers": { - "tooltip": "URL 帮助程序", - "description": "这些帮助程序尝试猜测服务的 URL。如果无效,只需手动输入地址。" - } - }, - "urlTemplate": { - "label": "Base URL", - "placeholder": "homelab.local 或 192.168.1.1", - "hint": "输入您的基础域或 IP 以自动填充集成 URL", - "warning": "现在设置基础 URL,将自动填充所有集成的 URL,在配置过程中为您节省时间", - "preview": "例如 {example}", - "mode": { - "subdomain": "子域名", - "hostPort": "主机:端口" - } - }, - "docker": { - "detected": "Docker 检测到 {count} 个服务", - "detectedBreakdown": "{integrations} 个集成 · {apps} 个应用", - "summary": "Homarr 检测到 {apps} 个应用,其中 {integrations} 个具有内置集成", - "appBadge": "应用", - "appSectionLabel": "要添加到面板的应用(已选择 {count} 个)", - "scanning": "正在扫描 Docker……", - "scanningDescription": "正在查找系统上运行的容器" - } - }, - "finish": { - "title": "完成设置", - "subtitle": "你已设置完成!", - "description": "您已成功完成设置过程。现在您可以开始使用 Homarr 了。选择下一步操作:", - "action": { - "goToBoard": "转到 {name} 面板", - "createBoard": "创建您的第一个面板", - "inviteUser": "邀请其他用户", - "docs": "阅读文档" - } - } - }, - "backToStart": "返回起点" - }, + "init": {}, "user": { "title": "用户", "name": "用户", @@ -4214,62 +4065,6 @@ "message": "您有尚未保存的更改。您确定要离开吗?" } }, - "oldImport": { - "label": "从 1.0.0 之前的 Homarr 导入", - "notification": { - "success": { - "title": "导入成功", - "message": "面板导入成功" - }, - "error": { - "title": "导入失败", - "message": "无法导入面板,请检查日志以获取更多详情" - } - }, - "form": { - "file": { - "label": "选择 JSON 文件", - "invalidError": "无效的配置文件" - }, - "apps": { - "label": "应用", - "avoidDuplicates": { - "label": "避免重复", - "description": "忽略已经存在具有相同链接的应用" - }, - "onlyImportApps": { - "label": "仅导入应用", - "description": "只添加应用,面板需要手动重新创建" - } - }, - "name": { - "label": "面板名称" - }, - "screenSize": { - "label": "屏幕大小", - "description": "在1.0之前的版本中,有三种不同的模式,所以你可以根据屏幕大小选择列的数量。", - "option": { - "sm": "小号", - "md": "中号", - "lg": "大号" - } - }, - "sidebarBehavior": { - "label": "侧边栏行为", - "description": "侧边栏在1.0中被删除了,您可以选择其中的项目应该发生什么。", - "option": { - "lastSection": { - "label": "最后部分", - "description": "侧边栏将显示在最后一部分的下方" - }, - "removeItems": { - "label": "删除项目", - "description": "侧边栏中包含的项目将被删除" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "在运行时创建应用", @@ -5889,9 +5684,6 @@ "newBoard": { "label": "创建面板" }, - "importBoard": { - "label": "导入面板" - }, "newApp": { "label": "创建应用" }, diff --git a/packages/translation/src/lang/cr.json b/packages/translation/src/lang/cr.json index d2b039fdc..0f3124515 100644 --- a/packages/translation/src/lang/cr.json +++ b/packages/translation/src/lang/cr.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "crwdns2264:0crwdne2264:0", - "subtitle": "crwdns2266:0crwdne2266:0", - "description": "crwdns2268:0crwdne2268:0", - "action": { - "scratch": "crwdns2270:0crwdne2270:0", - "importOldmarr": "crwdns2272:0crwdne2272:0", - "restoreSqlite": "crwdns4601:0crwdne4601:0", - "restoreSqliteDescription": "crwdns4603:0crwdne4603:0", - "backToOptions": "crwdns4605:0crwdne4605:0" - } - }, - "import": { - "title": "crwdns2274:0crwdne2274:0", - "subtitle": "crwdns2276:0crwdne2276:0", - "dropzone": { - "title": "crwdns2278:0crwdne2278:0", - "description": "crwdns2280:0crwdne2280:0" - }, - "fileInfo": { - "action": { - "change": "crwdns2282:0crwdne2282:0" - } - }, - "importSettings": { - "title": "crwdns2284:0crwdne2284:0", - "description": "crwdns2286:0crwdne2286:0" - }, - "boardSelection": { - "title": "crwdns2288:0{count}crwdne2288:0", - "description": "crwdns2692:0crwdne2692:0", - "action": { - "selectAll": "crwdns2292:0crwdne2292:0", - "unselectAll": "crwdns2294:0crwdne2294:0" - } - }, - "summary": { - "title": "crwdns2296:0crwdne2296:0", - "description": "crwdns2298:0crwdne2298:0", - "action": { - "import": "crwdns2300:0crwdne2300:0" - }, - "entities": { - "apps": "crwdns2302:0crwdne2302:0", - "boards": "crwdns2304:0crwdne2304:0", - "integrations": "crwdns2306:0crwdne2306:0", - "credentialUsers": "crwdns2308:0crwdne2308:0" - } - }, - "tokenModal": { - "title": "crwdns2310:0crwdne2310:0", - "field": { - "token": { - "label": "crwdns2312:0crwdne2312:0", - "description": "crwdns2314:0crwdne2314:0" - } - }, - "notification": { - "error": { - "title": "crwdns2316:0crwdne2316:0", - "message": "crwdns2318:0crwdne2318:0" - } - } - } - }, - "user": { - "title": "crwdns2320:0crwdne2320:0", - "subtitle": "crwdns2322:0crwdne2322:0", - "notification": { - "success": { - "title": "crwdns2324:0crwdne2324:0", - "message": "crwdns2326:0crwdne2326:0" - }, - "error": { - "title": "crwdns2328:0crwdne2328:0" - } - } - }, - "group": { - "title": "crwdns2330:0crwdne2330:0", - "subtitle": "crwdns2332:0crwdne2332:0", - "form": { - "name": { - "label": "crwdns2334:0crwdne2334:0", - "description": "crwdns2336:0crwdne2336:0" - } - } - }, - "settings": { - "title": "crwdns2338:0crwdne2338:0", - "subtitle": "crwdns2340:0crwdne2340:0" - }, - "integrations": { - "title": "crwdns4369:0crwdne4369:0", - "subtitle": "crwdns4371:0crwdne4371:0", - "description": "crwdns4373:0crwdne4373:0", - "action": { - "continue": "crwdns4375:0crwdne4375:0", - "skip": "crwdns4377:0crwdne4377:0" - }, - "configure": { - "progress": "crwdns4379:0{current}crwdnd4379:0{total}crwdne4379:0", - "skip": "crwdns4381:0crwdne4381:0", - "finishing": "crwdns4383:0crwdne4383:0", - "error": { - "title": "crwdns4385:0crwdne4385:0", - "message": "crwdns4387:0crwdne4387:0" - }, - "helpers": { - "tooltip": "crwdns4389:0crwdne4389:0", - "description": "crwdns4391:0crwdne4391:0" - } - }, - "urlTemplate": { - "label": "crwdns4393:0crwdne4393:0", - "placeholder": "crwdns4395:0crwdne4395:0", - "hint": "crwdns4397:0crwdne4397:0", - "warning": "crwdns4399:0crwdne4399:0", - "preview": "crwdns4401:0{example}crwdne4401:0", - "mode": { - "subdomain": "crwdns4403:0crwdne4403:0", - "hostPort": "crwdns4405:0crwdne4405:0" - } - }, - "docker": { - "detected": "crwdns4407:0{count}crwdne4407:0", - "detectedBreakdown": "crwdns4409:0{integrations}crwdnd4409:0{apps}crwdne4409:0", - "summary": "crwdns4411:0{apps}crwdnd4411:0{integrations}crwdne4411:0", - "appBadge": "crwdns4413:0crwdne4413:0", - "appSectionLabel": "crwdns4415:0{count}crwdne4415:0", - "scanning": "crwdns4417:0crwdne4417:0", - "scanningDescription": "crwdns4419:0crwdne4419:0" - } - }, - "finish": { - "title": "crwdns2342:0crwdne2342:0", - "subtitle": "crwdns2344:0crwdne2344:0", - "description": "crwdns2346:0crwdne2346:0", - "action": { - "goToBoard": "crwdns2348:0{name}crwdne2348:0", - "createBoard": "crwdns2350:0crwdne2350:0", - "inviteUser": "crwdns2352:0crwdne2352:0", - "docs": "crwdns2354:0crwdne2354:0" - } - } - }, - "backToStart": "crwdns2356:0crwdne2356:0" - }, + "init": {}, "user": { "title": "crwdns2:0crwdne2:0", "name": "crwdns4:0crwdne4:0", @@ -4058,62 +3909,6 @@ "message": "crwdns1380:0crwdne1380:0" } }, - "oldImport": { - "label": "crwdns1382:0crwdne1382:0", - "notification": { - "success": { - "title": "crwdns1384:0crwdne1384:0", - "message": "crwdns1386:0crwdne1386:0" - }, - "error": { - "title": "crwdns1388:0crwdne1388:0", - "message": "crwdns1390:0crwdne1390:0" - } - }, - "form": { - "file": { - "label": "crwdns1392:0crwdne1392:0", - "invalidError": "crwdns1394:0crwdne1394:0" - }, - "apps": { - "label": "crwdns1396:0crwdne1396:0", - "avoidDuplicates": { - "label": "crwdns1398:0crwdne1398:0", - "description": "crwdns1400:0crwdne1400:0" - }, - "onlyImportApps": { - "label": "crwdns1402:0crwdne1402:0", - "description": "crwdns1404:0crwdne1404:0" - } - }, - "name": { - "label": "crwdns1406:0crwdne1406:0" - }, - "screenSize": { - "label": "crwdns1408:0crwdne1408:0", - "description": "crwdns2368:0crwdne2368:0", - "option": { - "sm": "crwdns1410:0crwdne1410:0", - "md": "crwdns1412:0crwdne1412:0", - "lg": "crwdns1414:0crwdne1414:0" - } - }, - "sidebarBehavior": { - "label": "crwdns1416:0crwdne1416:0", - "description": "crwdns1418:0crwdne1418:0", - "option": { - "lastSection": { - "label": "crwdns1420:0crwdne1420:0", - "description": "crwdns1422:0crwdne1422:0" - }, - "removeItems": { - "label": "crwdns1424:0crwdne1424:0", - "description": "crwdns1426:0crwdne1426:0" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "crwdns2810:0crwdne2810:0", @@ -5737,9 +5532,6 @@ "newBoard": { "label": "crwdns2082:0crwdne2082:0" }, - "importBoard": { - "label": "crwdns2084:0crwdne2084:0" - }, "newApp": { "label": "crwdns2086:0crwdne2086:0" }, diff --git a/packages/translation/src/lang/cs.json b/packages/translation/src/lang/cs.json index 1c7eeaa15..f0c177bf6 100644 --- a/packages/translation/src/lang/cs.json +++ b/packages/translation/src/lang/cs.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Vítejte v Homarru", - "subtitle": "Pojďme začít nastavením instance Homarru.", - "description": "Chcete-li začít, zvolte, jak chcete nastavit instanci Homarru.", - "action": { - "scratch": "Začít od začátku", - "importOldmarr": "Import z Homarru před verzí 1.0", - "restoreSqlite": "Obnovit ze zálohy SQLite", - "restoreSqliteDescription": "Nahrajte kompletní zálohu SQLite databáze z předchozí instance Homarru. Tím se obnoví všechny plochy, uživatelé, aplikace, integrace a nastavení.", - "backToOptions": "← Zpět na možnosti nastavení" - } - }, - "import": { - "title": "Import dat", - "subtitle": "Můžete importovat data z existující instance Homarru.", - "dropzone": { - "title": "Přetáhněte zip soubor sem nebo klikněte pro procházení souborů", - "description": "Nahraný zip soubor bude zpracován a budete si moci vybrat, co chcete importovat" - }, - "fileInfo": { - "action": { - "change": "Změnit soubor" - } - }, - "importSettings": { - "title": "Importovat nastavení", - "description": "Konfigurovat chování importu" - }, - "boardSelection": { - "title": "Nalezeno {count} ploch", - "description": "Vyberte všechny plochy s velikostmi, které chcete importovat", - "action": { - "selectAll": "Vybrat vše", - "unselectAll": "Zrušit vybraní všeho" - } - }, - "summary": { - "title": "Přehled importu", - "description": "V následujícím přehledu můžete vidět, co bude importováno", - "action": { - "import": "Potvrdit import a pokračovat" - }, - "entities": { - "apps": "Aplikace", - "boards": "Plochy", - "integrations": "Integrace", - "credentialUsers": "Uživatelské oprávnění" - } - }, - "tokenModal": { - "title": "Zadejte token pro import", - "field": { - "token": { - "label": "Token", - "description": "Zadejte zobrazený importní token z předchozí instance homarr" - } - }, - "notification": { - "error": { - "title": "Neplatný token", - "message": "Token, který jste zadali je neplatný" - } - } - } - }, - "user": { - "title": "Správce", - "subtitle": "Zadejte přihlašovací údaje pro administrátora.", - "notification": { - "success": { - "title": "Uživatel byl vytvořen", - "message": "Uživatel byl úspěšně vytvořen" - }, - "error": { - "title": "Vytvoření uživatele se nezdařilo" - } - } - }, - "group": { - "title": "Externí skupina", - "subtitle": "Zadejte skupinu, která by měla být použita pro externí uživatele.", - "form": { - "name": { - "label": "Název skupiny", - "description": "Název musí odpovídat skupině admin externího poskytovatele" - } - } - }, - "settings": { - "title": "Nastavení", - "subtitle": "Konfigurace nastavení serveru." - }, - "integrations": { - "title": "Integrace", - "subtitle": "Vyberte služby, které používáte.", - "description": "Vyberte integrace, které chcete připojit. Automaticky nastavíme vaši plochu s odpovídajícími widgety.", - "action": { - "continue": "Pokračovat", - "skip": "Prozatím přeskočit" - }, - "configure": { - "progress": "{current} z {total}", - "skip": "Přeskočit tuto integraci", - "finishing": "Nastavování vaší plochy...", - "error": { - "title": "Nastavení se nezdařilo", - "message": "Během nastavování vaší plochy se něco pokazilo. Zkuste to prosím znovu." - }, - "helpers": { - "tooltip": "Pomocníci s URL", - "description": "Tito pomocníci se pokusí uhodnout URL vašich služeb. Pokud to nefunguje, zadejte adresu ručně." - } - }, - "urlTemplate": { - "label": "Základní URL", - "placeholder": "homelab.local nebo 192.168.1.1", - "hint": "Zadejte svou základní doménu nebo IP pro automatické vyplnění integračních URL", - "warning": "Nastavením základní URL nyní se automaticky vyplní všechna integrační URL, což vám ušetří čas během nastavování", - "preview": "např. {example}", - "mode": { - "subdomain": "Subdoména", - "hostPort": "Hostitel:Port" - } - }, - "docker": { - "detected": "Docker detekoval {count} služeb", - "detectedBreakdown": "{integrations} integrací · {apps} aplikací", - "summary": "Homarr detekoval {apps} aplikací, {integrations} z nich má vestavěné integrace", - "appBadge": "Aplikace", - "appSectionLabel": "Aplikace k přidání na plochu ({count} vybráno)", - "scanning": "Skenování Dockeru...", - "scanningDescription": "Hledání běžících kontejnerů ve vašem systému" - } - }, - "finish": { - "title": "Dokončit nastavení", - "subtitle": "Jste připraveni začít!", - "description": "Úspěšně jste dokončili proces nastavení. Nyní můžete začít používat Homarr. Vyberte další akci:", - "action": { - "goToBoard": "Přejít na plochu {name}", - "createBoard": "Vytvořte svou první plochu", - "inviteUser": "Pozvat ostatní uživatele", - "docs": "Přečtěte si dokumentaci" - } - } - }, - "backToStart": "Zpět na začátek" - }, + "init": {}, "user": { "title": "Uživatelé", "name": "Uživatel", @@ -3989,62 +3840,6 @@ "message": "Máte neuložené změny, jste si jistí, že chcete odejít?" } }, - "oldImport": { - "label": "", - "notification": { - "success": { - "title": "", - "message": "" - }, - "error": { - "title": "", - "message": "Plochu se nepodařilo importovat, zkontrolujte logy pro více informací" - } - }, - "form": { - "file": { - "label": "", - "invalidError": "" - }, - "apps": { - "label": "Aplikace", - "avoidDuplicates": { - "label": "", - "description": "" - }, - "onlyImportApps": { - "label": "", - "description": "" - } - }, - "name": { - "label": "" - }, - "screenSize": { - "label": "", - "description": "", - "option": { - "sm": "Malé", - "md": "Střední", - "lg": "Velké" - } - }, - "sidebarBehavior": { - "label": "", - "description": "", - "option": { - "lastSection": { - "label": "", - "description": "" - }, - "removeItems": { - "label": "", - "description": "" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "Vytvořit novou plochu" }, - "importBoard": { - "label": "" - }, "newApp": { "label": "" }, diff --git a/packages/translation/src/lang/da.json b/packages/translation/src/lang/da.json index 28f143269..9eb7db97b 100644 --- a/packages/translation/src/lang/da.json +++ b/packages/translation/src/lang/da.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Velkommen til Homarr", - "subtitle": "Lad os komme i gang med at konfigurere din Homarr instans.", - "description": "For at komme i gang, skal du vælge, hvordan du vil konfigurere din Homarr instans.", - "action": { - "scratch": "Start fra bunden", - "importOldmarr": "Importer fra Homarr før 1.0", - "restoreSqlite": "Gendan fra SQLite backup", - "restoreSqliteDescription": "Upload en fuld SQLite database backup fra en tidligere Homarr instans. Dette vil gendanne alle tavler, brugere, apps, integrationer og indstillinger.", - "backToOptions": "← Tilbage til opsætningsmuligheder" - } - }, - "import": { - "title": "Importer data", - "subtitle": "Du kan importere data fra en eksisterende Homarr instans.", - "dropzone": { - "title": "Træk zip-filen her eller klik for at gennemse", - "description": "Den uploadede zip vil blive behandlet, og du vil kunne vælge, hvad du vil importere" - }, - "fileInfo": { - "action": { - "change": "Ændre fil" - } - }, - "importSettings": { - "title": "Importer indstillinger", - "description": "Konfigurer importadfærden" - }, - "boardSelection": { - "title": "Fundet {count} tavler", - "description": "Vælg alle tavler med den størrelse, du vil importere", - "action": { - "selectAll": "Vælg alle", - "unselectAll": "Fravælg alle" - } - }, - "summary": { - "title": "Importér sammendrag", - "description": "I nedenstående oversigt kan du se hvad der vil blive importeret", - "action": { - "import": "Bekræft import og fortsæt" - }, - "entities": { - "apps": "Apps", - "boards": "Tavler", - "integrations": "Integrationer", - "credentialUsers": "Brugere, der er legitimerede" - } - }, - "tokenModal": { - "title": "Angiv import token", - "field": { - "token": { - "label": "Token", - "description": "Indtast den viste import token fra din tidligere homarr instans" - } - }, - "notification": { - "error": { - "title": "Ugyldig token", - "message": "Den indtastede token er ugyldig" - } - } - } - }, - "user": { - "title": "Admin bruger", - "subtitle": "Angiv legitimationsoplysninger for din administrator bruger.", - "notification": { - "success": { - "title": "Bruger oprettet", - "message": "Brugeren blev oprettet" - }, - "error": { - "title": "Brugeroprettelse mislykkedes" - } - } - }, - "group": { - "title": "Ekstern gruppe", - "subtitle": "Angiv den gruppe, der skal bruges til eksterne brugere.", - "form": { - "name": { - "label": "Gruppenavn", - "description": "Navn skal matche admin gruppe fra ekstern udbyder" - } - } - }, - "settings": { - "title": "Indstillinger", - "subtitle": "Konfigurer serverindstillinger." - }, - "integrations": { - "title": "Integrationer", - "subtitle": "Vælg de tjenester, du bruger.", - "description": "Vælg de integrationer, du gerne vil forbinde. Vi konfigurerer dit dashboard med matchende widgets automatisk.", - "action": { - "continue": "Forsæt", - "skip": "Spring over for nu" - }, - "configure": { - "progress": "{current} af {total}", - "skip": "Spring over denne integration", - "finishing": "Sætter dit dashboard op...", - "error": { - "title": "Opsætning mislykkedes", - "message": "Der opstod en fejl under opsætningen af dit dashboard. Prøv igen." - }, - "helpers": { - "tooltip": "URL-hjælpere", - "description": "Disse hjælpere forsøger at gætte URL'en til dine tjenester. Hvis det ikke virker, skal du blot indtaste adressen manuelt." - } - }, - "urlTemplate": { - "label": "Basis-URL", - "placeholder": "homelab.local eller 192.168.1.1", - "hint": "Indtast dit basisdomæne eller IP for automatisk at udfylde integrations-URL'er", - "warning": "Hvis du indstiller din basis-URL nu, udfyldes alle integrations-URL'er automatisk, hvilket sparer dig tid under opsætningen", - "preview": "f.eks. {example}", - "mode": { - "subdomain": "Subdomæne", - "hostPort": "Vært:Port" - } - }, - "docker": { - "detected": "Docker opdagede {count} tjenester", - "detectedBreakdown": "{integrations} integrationer · {apps} apps", - "summary": "Homarr opdagede {apps} apps, {integrations} heraf har indbyggede integrationer", - "appBadge": "App", - "appSectionLabel": "Apps der skal tilføjes til dashboard ({count} valgt)", - "scanning": "Scanner Docker...", - "scanningDescription": "Leder efter kørende containere på dit system" - } - }, - "finish": { - "title": "Afslut opsætningen", - "subtitle": "Du er klar!", - "description": "Du har fuldført opsætningsprocessen. Du kan nu begynde at bruge Homarr. Vælg din næste handling:", - "action": { - "goToBoard": "Gå til {name} tavle", - "createBoard": "Opret din første tavle", - "inviteUser": "Inviter andre brugere", - "docs": "Læs dokumentationen" - } - } - }, - "backToStart": "Tilbage til start" - }, + "init": {}, "user": { "title": "Brugere", "name": "Bruger", @@ -3989,62 +3840,6 @@ "message": "Du har ændringer, der ikke er gemt. Er du sikker på, at du vil forlade siden?" } }, - "oldImport": { - "label": "Importer fra homarr før 1.0.0", - "notification": { - "success": { - "title": "Import udført", - "message": "Tavlen blev importeret" - }, - "error": { - "title": "Import mislykkedes", - "message": "Tavlen kunne ikke importeres, tjek logfilerne for yderligere detaljer" - } - }, - "form": { - "file": { - "label": "Vælg JSON fil", - "invalidError": "Ugyldig konfigurationsfil" - }, - "apps": { - "label": "Apps", - "avoidDuplicates": { - "label": "Undgå dubletter", - "description": "Ignorerer apps, hvor en app med samme href allerede findes" - }, - "onlyImportApps": { - "label": "Importér kun apps", - "description": "Kun tilføjer apps, tavlen skal genskabes manuelt" - } - }, - "name": { - "label": "Navn på tavle" - }, - "screenSize": { - "label": "Skærmstørrelse", - "description": "I versioner før 1.0 tre forskellige tilstande eksisterede, så du var i stand til at vælge mængden af kolonner for hver skærmstørrelse.", - "option": { - "sm": "Lille", - "md": "Mellem", - "lg": "Stor" - } - }, - "sidebarBehavior": { - "label": "Sidebar opførsel", - "description": "Sidebars blev fjernet i 1.0, kan du vælge, hvad der skal ske med elementerne fra dem.", - "option": { - "lastSection": { - "label": "Sidste afsnit", - "description": "Sidebjælken vil blive vist under sidste sektion" - }, - "removeItems": { - "label": "Fjern elementer", - "description": "Elementer i sidepanelet vil blive fjernet" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Opret app i farten", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "Opret en ny tavle" }, - "importBoard": { - "label": "Importer en tavle" - }, "newApp": { "label": "Oprette en ny app" }, diff --git a/packages/translation/src/lang/de-CH.json b/packages/translation/src/lang/de-CH.json index fe05c75ca..eff575dd2 100644 --- a/packages/translation/src/lang/de-CH.json +++ b/packages/translation/src/lang/de-CH.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Willkomme zu Homarr", - "subtitle": "Lass uns mit der Einrichtung deiner Homarr-Instanz beginnen.", - "description": "Um loszulegen, wähle bitte wie du deine Homarr Instanz einrichten möchtest.", - "action": { - "scratch": "Vo vorne starte", - "importOldmarr": "Importiärä us Homarr vor 1.0", - "restoreSqlite": "", - "restoreSqliteDescription": "", - "backToOptions": "" - } - }, - "import": { - "title": "Date importiärä", - "subtitle": "Du casch date us ere existierende Homarr Instanz importiere.", - "dropzone": { - "title": "Zieh e ZIP-Datei hiä ane oder klick druf um z durchsueche", - "description": "Die uechegladeni ZIP Datei wird verarbeitet und du wirsch chönnä uswähle, was importiere wotsch" - }, - "fileInfo": { - "action": { - "change": "Datei ändere" - } - }, - "importSettings": { - "title": "Istellige importiere", - "description": "Import verhalte konfiguriere" - }, - "boardSelection": { - "title": "{count} boards gfunge", - "description": "Wähl alli boards, mit dr grössi wo de wotsch importiere, us", - "action": { - "selectAll": "Alli uswähle", - "unselectAll": "Gesamte Auswahl aufheben" - } - }, - "summary": { - "title": "Import zämäfassig", - "description": "Ir zämäfassig unge gsehsch, was importier wird", - "action": { - "import": "Bestätige und witer" - }, - "entities": { - "apps": "Apps", - "boards": "Boads", - "integrations": "Integratione", - "credentialUsers": "Benutzer mit Zugangsdaten" - } - }, - "tokenModal": { - "title": "Import token igäh", - "field": { - "token": { - "label": "Token", - "description": "Gib z import token vo dire vorherige Homarr Instanz ah" - } - }, - "notification": { - "error": { - "title": "Invalids token", - "message": "Z token wo de hesch agäh isch invalid" - } - } - } - }, - "user": { - "title": "Administrator benutzer", - "subtitle": "Gib z Zugangsdate für di Administrator benutzer ah.", - "notification": { - "success": { - "title": "Benutzer ersteut", - "message": "Dr Benutzer isch erfougrich ersteut worde" - }, - "error": { - "title": "Benutzerersteuig isch fehlgschlagge" - } - } - }, - "group": { - "title": "Externi Gruppe", - "subtitle": "Gib d gruppe ah wo für externi Benutzer verwendet werde söu", - "form": { - "name": { - "label": "Guppename", - "description": "Name muss mit der Admin-Gruppe des externen Anbieters übereinstimmen" - } - } - }, - "settings": { - "title": "Istellige", - "subtitle": "Serveristellige apasse." - }, - "integrations": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "continue": "", - "skip": "" - }, - "configure": { - "progress": "", - "skip": "", - "finishing": "", - "error": { - "title": "", - "message": "" - }, - "helpers": { - "tooltip": "", - "description": "" - } - }, - "urlTemplate": { - "label": "", - "placeholder": "", - "hint": "", - "warning": "", - "preview": "", - "mode": { - "subdomain": "", - "hostPort": "" - } - }, - "docker": { - "detected": "", - "detectedBreakdown": "", - "summary": "", - "appBadge": "", - "appSectionLabel": "", - "scanning": "", - "scanningDescription": "" - } - }, - "finish": { - "title": "Irichtig abschliesse", - "subtitle": "Du bisch startklar!", - "description": "Du hast die Einrichtung erfolgreich abgeschlossen. Du kannst jetzt mit der Nutzung von Homarr beginnen. Wähle deine nächste Aktion:", - "action": { - "goToBoard": "Gehe zu {name} Board", - "createBoard": "Erstelle dein erstes Board", - "inviteUser": "Andere Benutzer einladen", - "docs": "Lese die Dokumentation" - } - } - }, - "backToStart": "Zurück zum Anfang" - }, + "init": {}, "user": { "title": "Benutzer", "name": "Benutzer", @@ -3989,62 +3840,6 @@ "message": "Sie haben ungespeicherte Änderungen, sind sie sicher dass sie abbrechen möchten?" } }, - "oldImport": { - "label": "Import von Homarr vor Version 1.0.0", - "notification": { - "success": { - "title": "Import erfolgreich", - "message": "Das Board wurde erfolgreich importiert" - }, - "error": { - "title": "Import fehlgeschlagen", - "message": "Das Board konnte nicht importiert werden, überprüfen Sie die Protokolle für weitere Details" - } - }, - "form": { - "file": { - "label": "Wähle eine JSON-Datei", - "invalidError": "Ungültige Konfigurationsdatei" - }, - "apps": { - "label": "", - "avoidDuplicates": { - "label": "Duplikate vermeiden", - "description": "Ignoriert Apps, bei denen eine App mit dem gleichen href bereits existiert" - }, - "onlyImportApps": { - "label": "Nur Apps importieren", - "description": "Fügt nur die Apps hinzu, das Board muss manuell neu erstellt werden" - } - }, - "name": { - "label": "Board Name" - }, - "screenSize": { - "label": "Bildschirmgröße", - "description": "In Versionen vor 1.0 gab es drei verschiedene Modi, so dass Sie die Anzahl der Spalten für jede Bildschirmgröße festlegen konnten.", - "option": { - "sm": "Klein", - "md": "Mittel", - "lg": "Groß" - } - }, - "sidebarBehavior": { - "label": "Verhalten der Seitenleiste", - "description": "Seitenleisten wurden in 1.0 entfernt, Sie können wählen, was mit den Elementen in ihnen passieren soll.", - "option": { - "lastSection": { - "label": "Letzter Abschnitt", - "description": "Seitenleiste wird unterhalb des letzten Abschnitts angezeigt" - }, - "removeItems": { - "label": "Elemente entfernen", - "description": "Elemente in der Seitenleiste werden entfernt" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Jetzt eine App erstellen", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "Erstellen Sie ein neues Board" }, - "importBoard": { - "label": "Ein Board importieren" - }, "newApp": { "label": "Neue App erstellen" }, diff --git a/packages/translation/src/lang/de.json b/packages/translation/src/lang/de.json index 1429075cf..3dab0e708 100644 --- a/packages/translation/src/lang/de.json +++ b/packages/translation/src/lang/de.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Willkommen bei Homarr", - "subtitle": "Lass uns mit der Einrichtung deiner Homarr-Instanz beginnen.", - "description": "Um loszulegen, wähle bitte wie du deine Homarr Instanz einrichten möchtest.", - "action": { - "scratch": "Von vorne beginnen", - "importOldmarr": "Import von Homarr vor 1.0", - "restoreSqlite": "Aus SQLite-Backup wiederherstellen", - "restoreSqliteDescription": "Laden Sie eine vollständige SQLite-Datenbanksicherung von einer vorherigen Homarr-Instanz hoch. Dies wird alle Boards, Benutzer, Apps, Integrationen und Einstellungen wiederherstellen.", - "backToOptions": "← Zurück zu Setup Optionen" - } - }, - "import": { - "title": "Daten importieren", - "subtitle": "Sie können Daten aus einer bestehenden Homarr-Instanz importieren.", - "dropzone": { - "title": "Ziehen Sie die Zip-Datei hierher oder klicken Sie zum Durchsuchen", - "description": "Die hochgeladene Zip-Datei wird bearbeitet und Sie können wählen, was Sie importieren möchten" - }, - "fileInfo": { - "action": { - "change": "Datei ändern" - } - }, - "importSettings": { - "title": "Einstellungen Importieren", - "description": "Importverhalten konfigurieren" - }, - "boardSelection": { - "title": "{count} Boards gefunden", - "description": "Wählen Sie alle Boards mit der Größe aus, die Sie importieren möchten", - "action": { - "selectAll": "Alles auswählen", - "unselectAll": "Gesamte Auswahl aufheben" - } - }, - "summary": { - "title": "Zusammenfassung des Imports", - "description": "In der folgenden Zusammenfassung sehen Sie, was importiert wird", - "action": { - "import": "Import bestätigen und fortfahren" - }, - "entities": { - "apps": "Applikationen", - "boards": "Boards", - "integrations": "Integrationen", - "credentialUsers": "Benutzer mit Zugangsdaten" - } - }, - "tokenModal": { - "title": "Importschlüssel eingeben", - "field": { - "token": { - "label": "Schlüssel", - "description": "Gib den angezeigten Importschlüssel von der vorherigen Homarr-Instanz ein" - } - }, - "notification": { - "error": { - "title": "Ungültiger Schlüssel", - "message": "Der eingegebene Schlüssel ist ungültig" - } - } - } - }, - "user": { - "title": "Administrator", - "subtitle": "Geben Sie die Zugangsdaten für Ihren Administrator-Benutzer an.", - "notification": { - "success": { - "title": "Benutzer erstellt", - "message": "Der Benutzer wurde erfolgreich erstellt" - }, - "error": { - "title": "Erstellung des Benutzers fehlgeschlagen" - } - } - }, - "group": { - "title": "Externe Gruppe", - "subtitle": "Gebe die Gruppe an, welche für externe Benutzer verwendet werden soll.", - "form": { - "name": { - "label": "Gruppenname", - "description": "Name muss mit der Admin-Gruppe des externen Anbieters übereinstimmen" - } - } - }, - "settings": { - "title": "Einstellungen", - "subtitle": "Server-Einstellungen konfigurieren." - }, - "integrations": { - "title": "Integrationen", - "subtitle": "Wählen Sie die von Ihnen verwendeten Dienste aus.", - "description": "Wählen Sie die Integrationen, die Sie verbinden möchten. Ihr Dashboard wird automatisch mit passenden Widgets eingerichtet.", - "action": { - "continue": "Weiter", - "skip": "Vorerst überspringen" - }, - "configure": { - "progress": "{current} von {total}", - "skip": "Diese Integration überspringen", - "finishing": "Dashboard wird eingerichtet...", - "error": { - "title": "Einrichtung fehlgeschlagen", - "message": "Beim Einrichten des Dashboards ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut." - }, - "helpers": { - "tooltip": "URL Helfer", - "description": "Diese Helfer versuchen die URL für deine Dienste zu erraten. Wenn dies nicht funktioniert, gib einfach die Adresse manuell ein." - } - }, - "urlTemplate": { - "label": "Basis URL", - "placeholder": "homelab.local oder 192.168.1.1", - "hint": "Geben Sie Ihre Basis-Domain oder IP ein, um die Integrations-URLs automatisch auszufüllen", - "warning": "Wenn Sie jetzt Ihre Basis-URL einstellen, werden alle Integration-URLs automatisch ausgefüllt, was Ihnen Zeit während des Setups spart", - "preview": "z.B. {example}", - "mode": { - "subdomain": "Sub-Domain", - "hostPort": "Host:Port" - } - }, - "docker": { - "detected": "Docker erkannte {count} Dienste", - "detectedBreakdown": "{integrations} Integrationen · {apps} Anwendungen", - "summary": "Homarr erkannte {apps} Anwendungen, {integrations} von denen Integrationen integriert sind", - "appBadge": "Anwendung", - "appSectionLabel": "Zum Dashboard hinzuzufügende Apps ({count} gewählt)", - "scanning": "Durchsuche Docker...", - "scanningDescription": "Suche nach laufenden Containern auf Ihrem System" - } - }, - "finish": { - "title": "Einrichtung abschließen", - "subtitle": "Es kann losgehen!", - "description": "Du hast die Einrichtung erfolgreich abgeschlossen. Du kannst jetzt mit der Nutzung von Homarr beginnen. Wähle deine nächste Aktion:", - "action": { - "goToBoard": "Gehe zu {name} Board", - "createBoard": "Erstelle dein erstes Board", - "inviteUser": "Andere Benutzer einladen", - "docs": "Lese die Dokumentation" - } - } - }, - "backToStart": "Zurück zum Anfang" - }, + "init": {}, "user": { "title": "Benutzer", "name": "Benutzer", @@ -4058,62 +3909,6 @@ "message": "Sie haben ungespeicherte Änderungen, sind sie sicher dass sie abbrechen möchten?" } }, - "oldImport": { - "label": "Import von Homarr vor Version 1.0.0", - "notification": { - "success": { - "title": "Import erfolgreich", - "message": "Das Board wurde erfolgreich importiert" - }, - "error": { - "title": "Import fehlgeschlagen", - "message": "Das Board konnte nicht importiert werden, überprüfen Sie die Protokolle für weitere Details" - } - }, - "form": { - "file": { - "label": "Wähle eine JSON-Datei", - "invalidError": "Ungültige Konfigurationsdatei" - }, - "apps": { - "label": "Apps", - "avoidDuplicates": { - "label": "Duplikate vermeiden", - "description": "Ignoriert Apps, bei denen eine App mit dem gleichen href bereits existiert" - }, - "onlyImportApps": { - "label": "Nur Apps importieren", - "description": "Fügt nur die Apps hinzu. Das Board selbst muss manuell neu erstellt werden" - } - }, - "name": { - "label": "Board Name" - }, - "screenSize": { - "label": "Bildschirmgröße", - "description": "In Versionen vor 1.0 gab es drei verschiedene Modi, so dass Sie die Anzahl der Spalten für jede Bildschirmgröße festlegen konnten.", - "option": { - "sm": "Klein", - "md": "Mittel", - "lg": "Groß" - } - }, - "sidebarBehavior": { - "label": "Verhalten der Seitenleiste", - "description": "Seitenleisten wurden in 1.0 entfernt, Sie können wählen, was mit den Elementen in ihnen passieren soll.", - "option": { - "lastSection": { - "label": "Letzter Abschnitt", - "description": "Seitenleiste wird unterhalb des letzten Abschnitts angezeigt" - }, - "removeItems": { - "label": "Elemente entfernen", - "description": "Elemente in der Seitenleiste werden entfernt" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Jetzt eine App erstellen", @@ -5733,9 +5528,6 @@ "newBoard": { "label": "Erstellen Sie ein neues Board" }, - "importBoard": { - "label": "Ein Board importieren" - }, "newApp": { "label": "Neue App erstellen" }, diff --git a/packages/translation/src/lang/el.json b/packages/translation/src/lang/el.json index 61c62b090..0dfe7bf34 100644 --- a/packages/translation/src/lang/el.json +++ b/packages/translation/src/lang/el.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Καλώς ήρθατε στο Homarr", - "subtitle": "", - "description": "", - "action": { - "scratch": "", - "importOldmarr": "", - "restoreSqlite": "Επαναφορά από αντίγραφο ασφαλείας SQLite", - "restoreSqliteDescription": "Μεταφορτώστε ένα πλήρες αντίγραφο ασφαλείας βάσης δεδομένων SQLite από προηγούμενη εγκατάσταση Homarr. Αυτό θα επαναφέρει όλους τους πίνακες, χρήστες, εφαρμογές, ενσωματώσεις και ρυθμίσεις.", - "backToOptions": "← Επιστροφή στις επιλογές ρύθμισης" - } - }, - "import": { - "title": "Εισαγωγή δεδομένων", - "subtitle": "", - "dropzone": { - "title": "", - "description": "" - }, - "fileInfo": { - "action": { - "change": "" - } - }, - "importSettings": { - "title": "Εισαγωγή ρυθμίσεων", - "description": "" - }, - "boardSelection": { - "title": "", - "description": "", - "action": { - "selectAll": "Επιλογή όλων", - "unselectAll": "Αποεπιλογή όλων" - } - }, - "summary": { - "title": "", - "description": "", - "action": { - "import": "" - }, - "entities": { - "apps": "Εφαρμογές", - "boards": "Πίνακες", - "integrations": "", - "credentialUsers": "" - } - }, - "tokenModal": { - "title": "", - "field": { - "token": { - "label": "", - "description": "" - } - }, - "notification": { - "error": { - "title": "", - "message": "" - } - } - } - }, - "user": { - "title": "Διαχειριστής", - "subtitle": "", - "notification": { - "success": { - "title": "Ο Χρήστης δημιουργήθηκε", - "message": "" - }, - "error": { - "title": "" - } - } - }, - "group": { - "title": "Εξωτερική ομάδα", - "subtitle": "", - "form": { - "name": { - "label": "Όνομα ομάδας", - "description": "" - } - } - }, - "settings": { - "title": "Ρυθμίσεις", - "subtitle": "Διαμόρφωση ρυθμίσεων διακομιστή." - }, - "integrations": { - "title": "Ενσωματώσεις", - "subtitle": "Επιλέξτε τις υπηρεσίες που χρησιμοποιείτε.", - "description": "Επιλέξτε τις ενσωματώσεις που θέλετε να συνδέσετε. Θα ρυθμίσουμε τον πίνακα σας με αντίστοιχα widgets αυτόματα.", - "action": { - "continue": "Συνέχεια", - "skip": "Παράλειψη για τώρα" - }, - "configure": { - "progress": "{current} από {total}", - "skip": "Παράλειψη αυτής της ενσωμάτωσης", - "finishing": "Ρύθμιση του πίνακα σας...", - "error": { - "title": "Αποτυχία ρύθμισης", - "message": "Κάτι πήγε στραβά κατά τη ρύθμιση του πίνακα σας. Παρακαλώ προσπαθήστε ξανά." - }, - "helpers": { - "tooltip": "Βοηθοί URL", - "description": "Αυτοί οι βοηθοί προσπαθούν να μαντέψουν το URL για τις υπηρεσίες σας. Αν δεν λειτουργήσει, απλά πληκτρολογήστε τη διεύθυνση χειροκίνητα." - } - }, - "urlTemplate": { - "label": "Βασικό URL", - "placeholder": "homelab.local ή 192.168.1.1", - "hint": "Εισαγάγετε το βασικό domain ή IP σας για αυτόματη συμπλήρωση των URL ενσωμάτωσης", - "warning": "Η ρύθμιση του βασικού URL τώρα θα συμπληρώσει αυτόματα όλα τα URL ενσωμάτωσης, εξοικονομώντας χρόνο κατά τη ρύθμιση", - "preview": "π.χ. {example}", - "mode": { - "subdomain": "Υποτομέας", - "hostPort": "Διακομιστής:Θύρα" - } - }, - "docker": { - "detected": "Ο Docker εντόπισε {count} υπηρεσίες", - "detectedBreakdown": "{integrations} ενσωματώσεις · {apps} εφαρμογές", - "summary": "Το Homarr εντόπισε {apps} εφαρμογές, {integrations} από τις οποίες έχουν ενσωματωμένες ενσωματώσεις", - "appBadge": "Εφαρμογή", - "appSectionLabel": "Εφαρμογές προς προσθήκη στον πίνακα ({count} επιλεγμένες)", - "scanning": "Σάρωση Docker...", - "scanningDescription": "Αναζήτηση εκτελούμενων containers στο σύστημά σας" - } - }, - "finish": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "goToBoard": "", - "createBoard": "", - "inviteUser": "", - "docs": "Διαβάστε την τεκμηρίωση" - } - } - }, - "backToStart": "" - }, + "init": {}, "user": { "title": "Χρήστες", "name": "Χρήστης", @@ -3989,62 +3840,6 @@ "message": "" } }, - "oldImport": { - "label": "", - "notification": { - "success": { - "title": "", - "message": "" - }, - "error": { - "title": "", - "message": "" - } - }, - "form": { - "file": { - "label": "", - "invalidError": "" - }, - "apps": { - "label": "Εφαρμογές", - "avoidDuplicates": { - "label": "", - "description": "" - }, - "onlyImportApps": { - "label": "", - "description": "" - } - }, - "name": { - "label": "" - }, - "screenSize": { - "label": "", - "description": "", - "option": { - "sm": "Μικρό", - "md": "Μεσαίο", - "lg": "Μεγάλο" - } - }, - "sidebarBehavior": { - "label": "", - "description": "", - "option": { - "lastSection": { - "label": "", - "description": "" - }, - "removeItems": { - "label": "", - "description": "" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "" }, - "importBoard": { - "label": "" - }, "newApp": { "label": "" }, diff --git a/packages/translation/src/lang/en-gb.json b/packages/translation/src/lang/en-gb.json index 908465523..2775b6a82 100644 --- a/packages/translation/src/lang/en-gb.json +++ b/packages/translation/src/lang/en-gb.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Welcome to Homarr", - "subtitle": "Let's start setting your Homarr instance up.", - "description": "To get started, please select how you want to continue.", - "action": { - "scratch": "Start from scratch", - "importOldmarr": "Import from a Homarr version before v1.0", - "restoreSqlite": "Restore from SQLite backup", - "restoreSqliteDescription": "Upload a full SQLite database backup from a previous Homarr instance. This will restore all boards, users, apps, integrations, and settings.", - "backToOptions": "← Back to setup options" - } - }, - "import": { - "title": "Import data", - "subtitle": "You can import data from an existing Homarr instance.", - "dropzone": { - "title": "Drag your ZIP file here or click to browse your files", - "description": "The ZIP file you uploaded will be processed and you will be able to select what you would like to import" - }, - "fileInfo": { - "action": { - "change": "Change file" - } - }, - "importSettings": { - "title": "Import settings", - "description": "Configure the import behaviour" - }, - "boardSelection": { - "title": "Found {count} boards", - "description": "Choose all the boards with the size you want to import", - "action": { - "selectAll": "Select all", - "unselectAll": "Deselect all" - } - }, - "summary": { - "title": "Import summary", - "description": "In the summary below, you can see what will be imported", - "action": { - "import": "Please confirm import and continue" - }, - "entities": { - "apps": "Applications", - "boards": "Boards", - "integrations": "Integrations", - "credentialUsers": "Credential users" - } - }, - "tokenModal": { - "title": "Enter import token", - "field": { - "token": { - "label": "Token", - "description": "Enter the import token shown in your previous Homarr instance" - } - }, - "notification": { - "error": { - "title": "Invalid Token", - "message": "Sorry, but the token you entered is invalid" - } - } - } - }, - "user": { - "title": "Administrative user", - "subtitle": "Please specify the credentials for your administrator user.", - "notification": { - "success": { - "title": "User created", - "message": "The user has been created successfully" - }, - "error": { - "title": "User creation failed" - } - } - }, - "group": { - "title": "External group", - "subtitle": "Please specify the group which should be used for external users.", - "form": { - "name": { - "label": "Group name", - "description": "The name needs to match the admin group of your external provider" - } - } - }, - "settings": { - "title": "Settings", - "subtitle": "Configure server settings" - }, - "integrations": { - "title": "Integrations", - "subtitle": "Select the services you use.", - "description": "Choose the integrations you'd like to connect. We'll set up your dashboard with matching widgets automatically.", - "action": { - "continue": "Continue", - "skip": "Skip for now" - }, - "configure": { - "progress": "{current} of {total}", - "skip": "Skip this integration", - "finishing": "Setting up your dashboard...", - "error": { - "title": "Setup failed", - "message": "Something went wrong while setting up your dashboard. Please try again." - }, - "helpers": { - "tooltip": "URL helpers", - "description": "These helpers try to guess the URL for your services. If it doesn't work, just type the address manually." - } - }, - "urlTemplate": { - "label": "Base URL", - "placeholder": "homelab.local or 192.168.1.1", - "hint": "Enter your base domain or IP to auto-fill integration URLs", - "warning": "Setting your base URL now will auto-fill all integration URLs, saving you time during setup", - "preview": "e.g. {example}", - "mode": { - "subdomain": "Subdomain", - "hostPort": "Host:Port" - } - }, - "docker": { - "detected": "Docker detected {count} services", - "detectedBreakdown": "{integrations} integrations · {apps} apps", - "summary": "Homarr detected {apps} apps, {integrations} of which have integrations built-in", - "appBadge": "App", - "appSectionLabel": "Apps to add to dashboard ({count} selected)", - "scanning": "Scanning Docker...", - "scanningDescription": "Looking for running containers on your system" - } - }, - "finish": { - "title": "Finish set-up", - "subtitle": "Congratulations, you are ready to go!", - "description": "You have successfully finished the setup process. You can now start using Homarr. Please select your next action:", - "action": { - "goToBoard": "Go to {name} board", - "createBoard": "Create your first board", - "inviteUser": "Invite other users", - "docs": "Read the documentation" - } - } - }, - "backToStart": "Go back to the start" - }, + "init": {}, "user": { "title": "Users", "name": "User", @@ -4014,62 +3865,6 @@ "message": "" } }, - "oldImport": { - "label": "", - "notification": { - "success": { - "title": "", - "message": "" - }, - "error": { - "title": "", - "message": "" - } - }, - "form": { - "file": { - "label": "", - "invalidError": "" - }, - "apps": { - "label": "", - "avoidDuplicates": { - "label": "", - "description": "" - }, - "onlyImportApps": { - "label": "", - "description": "" - } - }, - "name": { - "label": "" - }, - "screenSize": { - "label": "", - "description": "", - "option": { - "sm": "", - "md": "", - "lg": "" - } - }, - "sidebarBehavior": { - "label": "", - "description": "", - "option": { - "lastSection": { - "label": "", - "description": "" - }, - "removeItems": { - "label": "", - "description": "" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "", @@ -5680,9 +5475,6 @@ "newBoard": { "label": "" }, - "importBoard": { - "label": "" - }, "newApp": { "label": "" }, diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index cf6988f57..20d0166d6 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1,153 +1,256 @@ { "init": { - "step": { - "start": { - "title": "Welcome to Homarr", - "subtitle": "Let's get started with setting up your Homarr instance.", - "description": "To get started, please select how you want to set up your Homarr instance.", - "action": { - "scratch": "Start from scratch", - "importOldmarr": "Import from Homarr before 1.0", - "restoreSqlite": "Restore from SQLite backup", - "restoreSqliteDescription": "Upload a full SQLite database backup from a previous Homarr instance. This will restore all boards, users, apps, integrations, and settings.", - "backToOptions": "← Back to setup options" + "studio": { + "setupBadge": "First-time setup", + "navigationLabel": "Onboarding sections", + "title": "Setup studio", + "subtitle": "Configure the essentials, let Homarr infer the rest.", + "back": "Back", + "continue": "Continue", + "buildBoard": "Build my board", + "common": { + "unknownError": "Something went wrong. Your choices are still here; try again." + }, + "claim": { + "checking": "Securing this onboarding session…", + "checkingTitle": "Preparing secure setup", + "lockedTitle": "Setup is open in another browser", + "lockedDescription": "Continue there, or wait for its secure first-run session to expire before trying again.", + "signInTitle": "Administrator sign-in required", + "signInDescription": "An administrator already exists. Sign in to resume setup without exposing credentials or service access.", + "signIn": "Sign in and resume", + "errorTitle": "Setup could not be secured", + "errorDescription": "Reload the page to retry the secure first-run session." + }, + "navigation": { + "essentials": "Essentials", + "discover": "Discover", + "connect": "Connect", + "board": "Board", + "extend": "Extend", + "review": "Review" + }, + "welcome": { + "badge": "New Homarr", + "eyebrow": "Your homelab, one useful board away", + "title": "Make home feel organized.", + "description": "Homarr can discover your services, connect the right integrations, and build a responsive first board. You only decide what cannot be inferred.", + "start": "Get started", + "restore": "Come from a backup", + "restoreTitle": "Restore this Homarr", + "restoreDescription": "A full SQLite backup restores boards, people, apps, integrations, and settings. Homarr validates it before replacing anything.", + "errorTitle": "Setup could not start", + "ready": "Ready to configure", + "panelTitle": "From services to a useful board", + "panelDescription": "Discovery, secure connections, layouts, widgets, Assistant, Workshop, and MCP stay connected in one flow.", + "chipDiscover": "Discover", + "chipConnect": "Connect", + "chipBuild": "Build" + }, + "account": { + "title": "Create your administrator", + "description": "This account owns the first setup and protects integration, Assistant, and MCP credentials.", + "username": "Administrator username", + "create": "Create administrator", + "errorTitle": "Administrator could not be created", + "signInError": "The account was created, but automatic sign-in failed. Sign in and setup will resume.", + "unknownError": "The administrator could not be created." + }, + "externalGroup": { + "title": "Connect your administrator group", + "description": "Use the exact group name sent by LDAP or OIDC. Members of this group receive administrator access.", + "name": "External administrator group", + "nameDescription": "This is case-sensitive and must match your identity provider.", + "continue": "Save group and continue", + "errorTitle": "Group could not be saved" + }, + "essentials": { + "eyebrow": "01 · Your Homarr", + "title": "Start with familiar defaults", + "description": "Set the language and appearance everyone sees first, then give Homarr the address pattern used by your services.", + "language": "Default language", + "theme": "Default theme", + "themeAuto": "System", + "themeLight": "Light", + "themeDark": "Dark", + "serverTitle": "Usual server address", + "serverDescription": "This is a shortcut, not a restriction. Every detected service remains editable.", + "serverOrigin": "Domain, IP, or reverse-proxy path", + "serverOriginDescription": "Examples: home.lan, 192.168.1.10, or https://homarr.example.com. URL paths are ignored when suggesting service addresses.", + "hostPort": "Same host, different ports", + "subdomain": "One subdomain per service", + "analytics": "Share anonymous product analytics", + "analyticsDescription": "Helps Homarr improve feature reliability. No service credentials or dashboard content are sent." + }, + "discover": { + "eyebrow": "02 · Capabilities", + "title": "See what this installation can actually reach", + "description": "Homarr checks capabilities in place. A missing Docker socket or Kubernetes context never blocks a manual setup.", + "database": "Database", + "docker": "Docker and compatible sockets", + "kubernetes": "Kubernetes", + "assistantWorkshop": "Assistant and Workshop", + "found": "Found {integrations} integrations and {apps} additional apps", + "retryDocker": "Scan Docker again", + "kubernetesAvailable": "Kubernetes tooling is enabled for this installation", + "kubernetesDisabled": "Not enabled; continue with Docker or manual services", + "workshopAvailable": "Community Workshop and hosted Assistant services are reachable", + "workshopUnavailable": "Local and custom Assistant providers remain available", + "partialTitle": "Some sources could not be scanned", + "partialDescription": "Results from reachable hosts are kept. Check socket permissions or add services manually in the next section.", + "platformTitle": "Docker, Synology, Kubernetes, bare metal—all welcome", + "platformDescription": "Homarr uses capabilities rather than guessing your platform. Detected values are suggestions you can review before anything is created.", + "status": { + "checking": "Checking availability…", + "available": "Available", + "partial": "Partially available", + "empty": "Reachable, no matching running services", + "disabled": "Disabled by server configuration", + "unavailable": "Unavailable from this Homarr process" } }, - "import": { - "title": "Import data", - "subtitle": "You can import data from an existing Homarr instance.", - "dropzone": { - "title": "Drag the zip file here or click to browse", - "description": "The uploaded zip will be processed and you'll be able to select what you want to import" + "connect": { + "eyebrow": "03 · Services", + "title": "Connect what makes the board useful", + "description": "Select several services at once. Docker labels take priority, image matching fills gaps, and every URL and credential stays reviewable.", + "search": "Search integrations", + "widgets": "{count} widgets", + "detected": "Detected", + "discoveredApps": "Additional detected apps ({count})", + "addressNeeded": "Address needed", + "empty": "No integrations selected. Homarr will still create a useful starter board with general widgets.", + "dockerSource": "Docker", + "manualSource": "Manual", + "name": "Name", + "url": "Service URL", + "noCredentials": "No credentials", + "noCredentialsDescription": "This integration does not need credentials.", + "getApiKey": "Open the API key settings for this service", + "openGuide": "Open Homarr's setup guide for this service", + "ready": "Ready", + "validationError": "Complete the URL and required credentials for {name}, or remove it from the selection.", + "connectionError": "Homarr could not connect to {name}. Check its URL and credentials; the rest of setup was not discarded." + }, + "board": { + "eyebrow": "04 · First board", + "title": "Shape the board before it is built", + "description": "Homarr creates protected Mobile and Base layouts, then places apps and compatible widgets from the services you selected.", + "target": "Board to configure", + "targetDescription": "Choose the exact existing board that this setup should update.", + "targetPlaceholder": "Select a board", + "targetRequired": "Select the board to configure before continuing.", + "name": "Board name", + "nameDescription": "Used in the board URL; letters, numbers, dashes, and underscores only.", + "primaryColor": "Primary color", + "secondaryColor": "Secondary color", + "radius": "Widget corner radius", + "layoutFocused": "Focused", + "layoutBalanced": "Balanced", + "layoutWide": "Wide", + "leftSidebar": "Left sidebar", + "rightSidebar": "Right sidebar", + "previewLabel": "Live preview of the first board layout", + "widgetPrimer": "A 30-second Homarr primer", + "primerAppsTitle": "Apps open things", + "primerAppsDescription": "App tiles are fast links with icons, status, and optional ping checks.", + "primerWidgetsTitle": "Widgets explain things", + "primerWidgetsDescription": "Integrations power focused views such as downloads, calendars, requests, and health.", + "primerEditTitle": "Everything stays editable", + "primerEditDescription": "Enter edit mode to move, resize, group, or add items independently on Mobile and Base layouts." + }, + "extend": { + "eyebrow": "05 · Power tools", + "title": "Add intelligence when it helps", + "description": "Assistant, Workshop, and MCP work together but remain independent. Configure any now or return later.", + "assistant": "Assistant", + "workshop": "Workshop", + "mcp": "MCP", + "assistantSetup": { + "title": "Homarr Assistant", + "description": "Ask about this Homarr, create and edit apps or boards with approval, inspect integrations, and use the same conversations in the Assistant widget.", + "loginTitle": "Administrator sign-in required", + "loginDescription": "Provider keys and model access are privileged. Finish setup, sign in, and Homarr will open the Assistant configuration directly.", + "loginAction": "Sign in to configure Assistant", + "provider": "Provider", + "baseUrl": "Provider URL", + "apiKey": "API key", + "connect": "Save connection and discover models", + "connectHint": "Keys are encrypted and never returned by the API.", + "model": "Default model", + "modelPlaceholder": "Connect a provider first", + "discovering": "Discovering models…", + "enable": "Enable Assistant", + "webSearch": "Allow supported web search", + "advanced": "Advanced headers and provider settings", + "save": "Save Assistant", + "savedTitle": "Assistant is ready", + "savedDescription": "The selected model is available from the header and Assistant widget.", + "errorTitle": "Assistant could not be configured" }, - "fileInfo": { - "action": { - "change": "Change file" - } + "workshopSetup": { + "title": "Homarr Workshop", + "description": "Browse community Custom Widgets, inspect their requested capabilities, install them, and publish your own work without copying code between servers.", + "available": "Available", + "unavailable": "Unavailable", + "discoverTitle": "Discover", + "discoverDescription": "Find widgets built for real self-hosted workflows.", + "installTitle": "Review and install", + "installDescription": "See metadata and configuration before adding anything.", + "publishTitle": "Share", + "publishDescription": "Publish reusable widgets back to the community.", + "browse": "Browse Workshop" }, - "importSettings": { - "title": "Import settings", - "description": "Configure the import behavior" - }, - "boardSelection": { - "title": "Found {count} boards", - "description": "Choose all boards with the size you want to import", - "action": { - "selectAll": "Select all", - "unselectAll": "Unselect all" - } - }, - "summary": { - "title": "Import summary", - "description": "In the below summary you can see what will be imported", - "action": { - "import": "Confirm import and continue" - }, - "entities": { - "apps": "Apps", - "boards": "Boards", - "integrations": "Integrations", - "credentialUsers": "Credential users" - } - }, - "tokenModal": { - "title": "Enter import token", - "field": { - "token": { - "label": "Token", - "description": "Enter the shown import token from your previous homarr instance" - } - }, - "notification": { - "error": { - "title": "Invalid token", - "message": "The token you entered is invalid" - } - } + "mcpSetup": { + "title": "Homarr MCP", + "description": "Connect Claude, Codex, or another MCP client to Homarr's typed tools. API keys keep access explicit and revocable.", + "endpoint": "Streamable HTTP endpoint", + "keyTitle": "Copy this key now", + "keyDescription": "For security, Homarr cannot show this API key again.", + "copyKey": "Copy API key", + "createKey": "Create an MCP API key", + "loginRequired": "Sign in as an administrator after setup to create an API key.", + "configuration": "Client configuration", + "copy": "Copy configuration", + "copied": "Copied", + "advanced": "Open MCP tools, permissions, and setup details" } }, - "user": { - "title": "Admin user", - "subtitle": "Specify the credentials for your administrator user.", - "notification": { - "success": { - "title": "User created", - "message": "The user was successfully created" - }, - "error": { - "title": "User creation failed" - } - } - }, - "group": { - "title": "External group", - "subtitle": "Specify the group that should be used for external users.", - "form": { - "name": { - "label": "Group name", - "description": "Name has to match admin group of external provider" - } - } - }, - "settings": { - "title": "Settings", - "subtitle": "Configure server settings." - }, - "integrations": { - "title": "Integrations", - "subtitle": "Select the services you use.", - "description": "Choose the integrations you'd like to connect. We'll set up your dashboard with matching widgets automatically.", - "action": { - "continue": "Continue", - "skip": "Skip for now" - }, - "configure": { - "progress": "{current} of {total}", - "skip": "Skip this integration", - "finishing": "Setting up your dashboard...", - "error": { - "title": "Setup failed", - "message": "Something went wrong while setting up your dashboard. Please try again." - }, - "helpers": { - "tooltip": "URL helpers", - "description": "These helpers try to guess the URL for your services. If it doesn't work, just type the address manually." - } - }, - "urlTemplate": { - "label": "Base URL", - "placeholder": "homelab.local or 192.168.1.1", - "hint": "Enter your base domain or IP to auto-fill integration URLs", - "warning": "Setting your base URL now will auto-fill all integration URLs, saving you time during setup", - "preview": "e.g. {example}", - "mode": { - "subdomain": "Subdomain", - "hostPort": "Host:Port" - } - }, - "docker": { - "detected": "Docker detected {count} services", - "detectedBreakdown": "{integrations} integrations · {apps} apps", - "summary": "Homarr detected {apps} apps, {integrations} of which have integrations built-in", - "appBadge": "App", - "appSectionLabel": "Apps to add to dashboard ({count} selected)", - "scanning": "Scanning Docker...", - "scanningDescription": "Looking for running containers on your system" + "review": { + "eyebrow": "06 · Review", + "title": "One board, built around your services", + "description": "Homarr tests unsaved connections, imports the selected apps, applies appearance and layouts, then opens the exact board it configured.", + "appearance": "Language and appearance", + "services": "Selected services", + "servicesValue": "{integrations} integrations · {apps} apps", + "board": "First board", + "layouts": "Responsive layouts", + "layoutsValue": "Mobile + Base", + "previewLabel": "Preview of the board being created", + "automaticTitle": "What Homarr will infer", + "automaticDescription": "Compatible widgets, icons, app links, integration relationships, and responsive placement come from the shared Homarr catalog. You can edit every result later.", + "dockerWarningTitle": "Some Docker labels changed during setup", + "dockerWarningDescription": "{count, plural, one {# selected Docker result could not be applied because its service changed, targeted another board, or its widget was incompatible} other {# selected Docker results could not be applied because services changed, targeted another board, or their widgets were incompatible}}.", + "errorTitle": "Setup paused safely", + "progress": { + "integration": "Testing and saving {name}", + "apps": "Importing selected apps", + "board": "Building Mobile and Base layouts", + "done": "Your board is ready" } }, "finish": { - "title": "Finish setup", - "subtitle": "You are ready to go!", - "description": "You have successfully completed the setup process. You can now start using Homarr. Select your next action:", - "action": { - "goToBoard": "Go to {name} board", - "createBoard": "Create your first board", - "inviteUser": "Invite other users", - "docs": "Read the documentation" - } + "title": "Your first board is ready.", + "description": "Homarr connected the pieces you selected and kept the rest editable. Open the board now; the short in-product tour can show you edit mode, widgets, layouts, and search.", + "openBoard": "Open my board", + "tutorial": "Tip: use edit mode to move or resize widgets. Mobile and Base layouts keep independent positions, so every screen can feel intentional.", + "manageTitle": "Manage", + "manageDescription": "Fine-tune apps, integrations, boards, users, and server settings.", + "workshopTitle": "Workshop", + "workshopDescription": "Discover and install community Custom Widgets.", + "docsTitle": "Learn Homarr", + "docsDescription": "Read the focused guides for boards, widgets, integrations, and tools." } - }, - "backToStart": "Back to start" + } }, "user": { "title": "Users", @@ -621,9 +724,18 @@ "page": { "list": { "title": "Apps", + "ariaLabel": "Configured apps", + "action": { + "edit": "Edit {name}", + "delete": "Delete {name}" + }, "noResults": { "title": "There are no apps yet", - "action": "Create your first app" + "description": "Apps are links to the services and tools you want on your boards.", + "action": "Create your first app", + "filteredTitle": "No apps match your search", + "filteredDescription": "No apps match \"{search}\". Try another name or URL.", + "clearSearch": "Clear search" } }, "create": { @@ -1365,9 +1477,19 @@ "page": { "list": { "title": "Integrations", + "ariaLabel": "Configured integrations", "search": "Search integrations", + "action": { + "edit": "Edit {name}", + "delete": "Delete {name}" + }, "noResults": { - "title": "There are no integrations yet" + "title": "There are no integrations yet", + "description": "Connect a service to use its data and actions in Homarr widgets.", + "action": "Create your first integration", + "filteredTitle": "No integrations match your search", + "filteredDescription": "No integrations match \"{search}\". Try another name, type, or URL.", + "clearSearch": "Clear search" } }, "create": { @@ -5481,62 +5603,6 @@ "message": "You have unsaved changes, are you sure you want to leave?" } }, - "oldImport": { - "label": "Import from homarr before 1.0.0", - "notification": { - "success": { - "title": "Import successful", - "message": "The board was successfully imported" - }, - "error": { - "title": "Import failed", - "message": "The board could not be imported, check the logs for further details" - } - }, - "form": { - "file": { - "label": "Select JSON file", - "invalidError": "Invalid configuration file" - }, - "apps": { - "label": "Apps", - "avoidDuplicates": { - "label": "Avoid duplicates", - "description": "Ignores apps where an app with the same href already exists" - }, - "onlyImportApps": { - "label": "Only import apps", - "description": "Only adds the apps, the board need to be recreated manually" - } - }, - "name": { - "label": "Board name" - }, - "screenSize": { - "label": "Screen size", - "description": "In versions before 1.0 three different modes existed, so you were able to choose the amount of columns for each screen size.", - "option": { - "sm": "Small", - "md": "Medium", - "lg": "Large" - } - }, - "sidebarBehavior": { - "label": "Sidebar behavior", - "description": "Sidebars were removed in 1.0, you can select what should happen with the items inside them.", - "option": { - "lastSection": { - "label": "Last section", - "description": "Sidebar will be displayed below the last section" - }, - "removeItems": { - "label": "Remove items", - "description": "Items contained in the sidebar will be removed" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Create app on the fly", @@ -5985,8 +6051,12 @@ "public": "This board is public", "private": "This board is private" }, + "preview": { + "label": "Layout preview for {name}, with {count} items" + }, "noResults": { - "title": "No boards yet" + "title": "No boards yet", + "description": "Create a board to arrange your apps and widgets in one place." }, "modal": { "createBoard": { @@ -7411,9 +7481,6 @@ "newBoard": { "label": "Create a new board" }, - "importBoard": { - "label": "Import a board" - }, "newApp": { "label": "Create a new app" }, @@ -7794,11 +7861,24 @@ "page": { "list": { "title": "Search engines", + "ariaLabel": "Configured search engines", + "action": { + "edit": "Edit {name}", + "delete": "Delete {name}" + }, "noResults": { "title": "There are no search engines yet", - "action": "Create your first search engine" + "description": "Search engines let Spotlight send a query to a site or connected service.", + "action": "Create your first search engine", + "filteredTitle": "No search engines match your search", + "filteredDescription": "No search engines match \"{search}\". Try another name or URL.", + "clearSearch": "Clear search" }, - "interactive": "Interactive, uses an integration" + "interactive": "Interactive, uses an integration", + "type": { + "generic": "URL template", + "fromIntegration": "Integration" + } }, "create": { "title": "New search engine", diff --git a/packages/translation/src/lang/es.json b/packages/translation/src/lang/es.json index 01d8213b2..cd0b80cd3 100644 --- a/packages/translation/src/lang/es.json +++ b/packages/translation/src/lang/es.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Bienvenido a Homarr", - "subtitle": "Comencemos con la configuración de tu instancia de Homarr.", - "description": "Para empezar, por favor, selecciona cómo deseas configurar tu instancia de Homarr.", - "action": { - "scratch": "Empezar de cero", - "importOldmarr": "Importar desde una versión anterior a Homarr 1.0", - "restoreSqlite": "", - "restoreSqliteDescription": "", - "backToOptions": "" - } - }, - "import": { - "title": "Importar datos", - "subtitle": "Puedes importar datos desde una instancia de Homarr existente.", - "dropzone": { - "title": "Arrastre el archivo ZIP aquí o haga clic para navegar", - "description": "El archivo ZIP subido será procesado y podrás seleccionar lo que quieres importar" - }, - "fileInfo": { - "action": { - "change": "Modificar el archivo" - } - }, - "importSettings": { - "title": "Importar ajustes", - "description": "Configurar el comportamiento de la importación" - }, - "boardSelection": { - "title": "Se encontraron {count} tableros", - "description": "Elige todos los tableros con el tamaño que deseas para importar", - "action": { - "selectAll": "Seleccionar todo", - "unselectAll": "Deshacer selección" - } - }, - "summary": { - "title": "Resumen de la importación", - "description": "En el resumen que se muestra a continuación puedes ver qué se importará", - "action": { - "import": "Confirmar la importación y continuar" - }, - "entities": { - "apps": "Aplicaciones", - "boards": "Tableros", - "integrations": "Integraciones", - "credentialUsers": "Credenciales de usuarios" - } - }, - "tokenModal": { - "title": "Introducir token de importación", - "field": { - "token": { - "label": "Token", - "description": "Introduzca el token de importación mostrado de su anterior instancia de Homarr" - } - }, - "notification": { - "error": { - "title": "Token no válido", - "message": "El token que has introducido no es válido" - } - } - } - }, - "user": { - "title": "Usuario administrador", - "subtitle": "Especifica las credenciales para tu usuario administrador.", - "notification": { - "success": { - "title": "Usuario creado", - "message": "El usuario se creó con éxito" - }, - "error": { - "title": "Error al crear el usuario" - } - } - }, - "group": { - "title": "Grupo externo", - "subtitle": "Especifique el grupo que debe ser usado para usuarios externos.", - "form": { - "name": { - "label": "Nombre del grupo", - "description": "El nombre debe coincidir con el grupo de administración del proveedor externo" - } - } - }, - "settings": { - "title": "Ajustes", - "subtitle": "Configurar ajustes del servidor." - }, - "integrations": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "continue": "", - "skip": "" - }, - "configure": { - "progress": "", - "skip": "", - "finishing": "", - "error": { - "title": "", - "message": "" - }, - "helpers": { - "tooltip": "", - "description": "" - } - }, - "urlTemplate": { - "label": "", - "placeholder": "", - "hint": "", - "warning": "", - "preview": "", - "mode": { - "subdomain": "", - "hostPort": "" - } - }, - "docker": { - "detected": "", - "detectedBreakdown": "", - "summary": "", - "appBadge": "", - "appSectionLabel": "", - "scanning": "", - "scanningDescription": "" - } - }, - "finish": { - "title": "Finalizar la configuración", - "subtitle": "¡Estás listo para empezar!", - "description": "Has completado el proceso de configuración inicial con éxito. Puedes empezar a utilizar Homarr. Selecciona tu próxima tarea:", - "action": { - "goToBoard": "Ir al tablero {name}", - "createBoard": "Crea tu primer tablero", - "inviteUser": "Invita a otros usuarios", - "docs": "Consulta la documentación" - } - } - }, - "backToStart": "Volver al inicio" - }, + "init": {}, "user": { "title": "Usuarios", "name": "Usuario", @@ -3989,62 +3840,6 @@ "message": "Tienes cambios sin guardar, ¿estás seguro de que quieres salir?" } }, - "oldImport": { - "label": "Importar desde una versión anterior a Homarr 1.0", - "notification": { - "success": { - "title": "Importación con éxito", - "message": "El tablero fue importado con éxito" - }, - "error": { - "title": "Error de importación", - "message": "El tablero no pudo ser importado, compruebe los registros para más detalles" - } - }, - "form": { - "file": { - "label": "Seleccionar archivo JSON", - "invalidError": "Archivo de configuración no válido" - }, - "apps": { - "label": "Aplicaciones", - "avoidDuplicates": { - "label": "Evitar duplicados", - "description": "Ignora aplicaciones donde ya existe una aplicación con el mismo href" - }, - "onlyImportApps": { - "label": "Importar solo aplicaciones", - "description": "Sólo añade las aplicaciones, el tablero debe ser recreado manualmente" - } - }, - "name": { - "label": "Nombre del tablero" - }, - "screenSize": { - "label": "Tamaño de pantalla", - "description": "En versiones anteriores a la 1.0 existían tres modos diferentes, por lo que podías elegir la cantidad de columnas para cada tamaño de pantalla.", - "option": { - "sm": "Pequeño", - "md": "Mediano", - "lg": "Grande" - } - }, - "sidebarBehavior": { - "label": "Comportamiento de la barra lateral", - "description": "Las barras laterales fueron eliminadas en la versión 1.0, puedes seleccionar lo que debería pasar con los elementos dentro de ellas.", - "option": { - "lastSection": { - "label": "Última sección", - "description": "La barra lateral se mostrará debajo de la última sección" - }, - "removeItems": { - "label": "Eliminar los elementos", - "description": "Los elementos contenidos en la barra lateral serán eliminados" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Crear aplicación en el momento", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "Crear un nuevo tablero" }, - "importBoard": { - "label": "Importar un tablero" - }, "newApp": { "label": "Crear una nueva aplicación" }, diff --git a/packages/translation/src/lang/et.json b/packages/translation/src/lang/et.json index 283e6da38..12004fafb 100644 --- a/packages/translation/src/lang/et.json +++ b/packages/translation/src/lang/et.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "scratch": "", - "importOldmarr": "", - "restoreSqlite": "", - "restoreSqliteDescription": "", - "backToOptions": "" - } - }, - "import": { - "title": "", - "subtitle": "", - "dropzone": { - "title": "", - "description": "" - }, - "fileInfo": { - "action": { - "change": "" - } - }, - "importSettings": { - "title": "", - "description": "" - }, - "boardSelection": { - "title": "", - "description": "", - "action": { - "selectAll": "", - "unselectAll": "" - } - }, - "summary": { - "title": "", - "description": "", - "action": { - "import": "" - }, - "entities": { - "apps": "", - "boards": "", - "integrations": "", - "credentialUsers": "" - } - }, - "tokenModal": { - "title": "", - "field": { - "token": { - "label": "", - "description": "" - } - }, - "notification": { - "error": { - "title": "", - "message": "" - } - } - } - }, - "user": { - "title": "", - "subtitle": "", - "notification": { - "success": { - "title": "", - "message": "" - }, - "error": { - "title": "" - } - } - }, - "group": { - "title": "", - "subtitle": "", - "form": { - "name": { - "label": "", - "description": "" - } - } - }, - "settings": { - "title": "", - "subtitle": "" - }, - "integrations": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "continue": "", - "skip": "" - }, - "configure": { - "progress": "", - "skip": "", - "finishing": "", - "error": { - "title": "", - "message": "" - }, - "helpers": { - "tooltip": "", - "description": "" - } - }, - "urlTemplate": { - "label": "", - "placeholder": "", - "hint": "", - "warning": "", - "preview": "", - "mode": { - "subdomain": "", - "hostPort": "" - } - }, - "docker": { - "detected": "", - "detectedBreakdown": "", - "summary": "", - "appBadge": "", - "appSectionLabel": "", - "scanning": "", - "scanningDescription": "" - } - }, - "finish": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "goToBoard": "", - "createBoard": "", - "inviteUser": "", - "docs": "" - } - } - }, - "backToStart": "" - }, + "init": {}, "user": { "title": "Kasutajad", "name": "Kasutaja", @@ -3989,62 +3840,6 @@ "message": "Teil on salvestamata muudatusi, kas olete kindel, et soovite lahkuda?" } }, - "oldImport": { - "label": "Impordi Homarr'ist enne 1.0.0", - "notification": { - "success": { - "title": "Importimine õnnestus", - "message": "Töölaud imporditi edukalt" - }, - "error": { - "title": "Importimine ebaõnnestus", - "message": "Töölauda ei saanud importida, vaadake logisid täpsema teabe saamiseks" - } - }, - "form": { - "file": { - "label": "Vali JSON fail", - "invalidError": "Vigane seadistusfail" - }, - "apps": { - "label": "Rakendused", - "avoidDuplicates": { - "label": "Väldi duplikaate", - "description": "Ignoreerib rakendusi, kui sama href'iga rakendus on juba olemas" - }, - "onlyImportApps": { - "label": "Impordi ainult rakendusi", - "description": "Lisab ainult rakendused, töölaud tuleb käsitsi uuesti luua" - } - }, - "name": { - "label": "Töölaua nimi" - }, - "screenSize": { - "label": "Ekraani suurus", - "description": "", - "option": { - "sm": "Väike", - "md": "Keskmine", - "lg": "Suur" - } - }, - "sidebarBehavior": { - "label": "Külgriba käitumine", - "description": "Külgribad eemaldati versioonis 1.0, saate valida, mis peaks juhtuma neis olevate üksustega.", - "option": { - "lastSection": { - "label": "Viimane jaotis", - "description": "Külgriba kuvatakse viimase jaotise all" - }, - "removeItems": { - "label": "Eemalda üksused", - "description": "Külgribal olevad üksused eemaldatakse" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "" }, - "importBoard": { - "label": "" - }, "newApp": { "label": "" }, diff --git a/packages/translation/src/lang/fi.json b/packages/translation/src/lang/fi.json index 6c8fe0f95..2d9c45bb1 100644 --- a/packages/translation/src/lang/fi.json +++ b/packages/translation/src/lang/fi.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Tervetuloa Homarriin", - "subtitle": "", - "description": "", - "action": { - "scratch": "", - "importOldmarr": "", - "restoreSqlite": "Palauta SQLite-varmuuskopiosta", - "restoreSqliteDescription": "Lataa täysi SQLite-tietokantavarmuuskopio aiemmasta Homarr-instanssista. Tämä palauttaa kaikki paneelit, käyttäjät, sovellukset, integraatiot ja asetukset.", - "backToOptions": "← Takaisin asennusvaihtoehtoihin" - } - }, - "import": { - "title": "", - "subtitle": "", - "dropzone": { - "title": "", - "description": "" - }, - "fileInfo": { - "action": { - "change": "" - } - }, - "importSettings": { - "title": "", - "description": "" - }, - "boardSelection": { - "title": "", - "description": "", - "action": { - "selectAll": "Valitse kaikki", - "unselectAll": "Tyhjennä valinnat" - } - }, - "summary": { - "title": "", - "description": "", - "action": { - "import": "" - }, - "entities": { - "apps": "", - "boards": "", - "integrations": "Integraatiot", - "credentialUsers": "" - } - }, - "tokenModal": { - "title": "", - "field": { - "token": { - "label": "", - "description": "" - } - }, - "notification": { - "error": { - "title": "", - "message": "" - } - } - } - }, - "user": { - "title": "Pääkäyttäjä", - "subtitle": "Määritä pääkäyttäjäsi kirjautumistiedot.", - "notification": { - "success": { - "title": "", - "message": "" - }, - "error": { - "title": "Käyttäjän luonti epäonnistui" - } - } - }, - "group": { - "title": "Ulkoinen ryhmä", - "subtitle": "Määritä ulkoisille käyttäjille käytettävä ryhmä.", - "form": { - "name": { - "label": "Ryhmän nimi", - "description": "Nimen tulee vastata ulkoisen palveluntarjoajan pääkäyttäjäryhmää" - } - } - }, - "settings": { - "title": "Asetukset", - "subtitle": "Määritä palvelinasetukset." - }, - "integrations": { - "title": "Integraatiot", - "subtitle": "Valitse käyttämäsi palvelut.", - "description": "Valitse integraatiot, jotka haluat yhdistää. Kojelaudallaesi lisätään automaattisesti yhteensopivia widgettejä.", - "action": { - "continue": "Jatka", - "skip": "Ohita toistaiseksi" - }, - "configure": { - "progress": "{current}/{total}", - "skip": "Ohita tämä integraatio", - "finishing": "Kojelautaasi määritetään...", - "error": { - "title": "Määritys epäonnistui", - "message": "Jotain meni pieleen kojelautaasi määritettäessä. Yritä uudelleen." - }, - "helpers": { - "tooltip": "URL-apurit", - "description": "Nämä apurit pyrkivät arvaamaan palveluidesi URL-osoitteet. Jos tämä ei toimi, kirjoita osoite manuaalisesti." - } - }, - "urlTemplate": { - "label": "Perus-URL", - "placeholder": "homelab.local tai 192.168.1.1", - "hint": "Syötä perusverkkotunnuksesi tai IP-osoite täyttääksesi integraation URL-osoitteet automaattisesti", - "warning": "Asettamalla perusosoitteen nyt, voidaan integraatioiden URL-osoitteet täyttää automaattisesti, joka nopeuttaa käyttöönottoa", - "preview": "esim. {example}", - "mode": { - "subdomain": "Aliverkkotunnus", - "hostPort": "Isäntä:Portti" - } - }, - "docker": { - "detected": "Docker löysi {count} palvelua", - "detectedBreakdown": "{integrations} integraatiota · {apps} sovellusta", - "summary": "Homarr havaitse {apps} sovellusta, joista {integrations} löytyy sisäänrakennettu integraatio", - "appBadge": "Sovellus", - "appSectionLabel": "Ohjauspaneeliin lisättävät sovellukset ({count} valittu)", - "scanning": "Dockeria tutkitaan...", - "scanningDescription": "Tunnistetaan järjestelmässäsi suoritettavia kontteja" - } - }, - "finish": { - "title": "Viimeistele määritys", - "subtitle": "Olet valmis aloittamaan!", - "description": "Olet suorittanut käyttöönoton onnistuneesti ja voit nyt aloittaa Homarrin käytön. Valitse seuraava toimenpiteesi:", - "action": { - "goToBoard": "Avaa paneeli {name}", - "createBoard": "Luo ensimmäinen paneelisi", - "inviteUser": "Kutsu muita käyttäjiä", - "docs": "Lue ohjeita" - } - } - }, - "backToStart": "Palaa alkuun" - }, + "init": {}, "user": { "title": "Käyttäjät", "name": "Käyttäjä", @@ -3989,62 +3840,6 @@ "message": "Muutoksia ei ole tallennettu. Haluatko varmasti poistua?" } }, - "oldImport": { - "label": "Tuo Homarr-versiota 1.0.0 vanhemmasta", - "notification": { - "success": { - "title": "", - "message": "" - }, - "error": { - "title": "", - "message": "" - } - }, - "form": { - "file": { - "label": "", - "invalidError": "" - }, - "apps": { - "label": "", - "avoidDuplicates": { - "label": "", - "description": "" - }, - "onlyImportApps": { - "label": "", - "description": "" - } - }, - "name": { - "label": "" - }, - "screenSize": { - "label": "", - "description": "", - "option": { - "sm": "", - "md": "", - "lg": "" - } - }, - "sidebarBehavior": { - "label": "", - "description": "", - "option": { - "lastSection": { - "label": "", - "description": "" - }, - "removeItems": { - "label": "", - "description": "" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Luo sovellus lennossa", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "Luo uusi paneeli" }, - "importBoard": { - "label": "Tuo paneeli" - }, "newApp": { "label": "Luo uusi sovellus" }, diff --git a/packages/translation/src/lang/fr.json b/packages/translation/src/lang/fr.json index 69595c004..1caff342b 100644 --- a/packages/translation/src/lang/fr.json +++ b/packages/translation/src/lang/fr.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Bienvenue sur Homarr", - "subtitle": "Commençons par configurer votre instance Homarr.", - "description": "Pour commencer, veuillez selectionner comment vous souhaitez configurer votre instance Homarr.", - "action": { - "scratch": "Partir de zéro", - "importOldmarr": "Importer depuis Homarr avant la 1.0", - "restoreSqlite": "Restaurer depuis une sauvegarde SQLite", - "restoreSqliteDescription": "Téléchargez une sauvegarde complète de la base de données SQLite depuis une instance Homarr précédente. Cela restaurera tous les tableaux, utilisateurs, applications, intégrations et paramètres.", - "backToOptions": "← Retour aux options de configuration" - } - }, - "import": { - "title": "Importer des données", - "subtitle": "Vous pouvez importer des données depuis une instance Homarr existante.", - "dropzone": { - "title": "Déposez le fichier zip ici ou cliquez pour naviguer", - "description": "Le zip téléchargé sera traité et vous pourrez sélectionner ce que vous voulez importer" - }, - "fileInfo": { - "action": { - "change": "Changer le fichier" - } - }, - "importSettings": { - "title": "Importer des paramètres", - "description": "Configurer le comportement d'importation" - }, - "boardSelection": { - "title": "{count} tableaux trouvés", - "description": "Choisissez tous les tableaux avec la taille que vous souhaitez importer", - "action": { - "selectAll": "Tout sélectionner", - "unselectAll": "Tout désélectionner" - } - }, - "summary": { - "title": "Résumé de l'importation", - "description": "Dans le résumé ci-dessous vous pouvez voir ce qui va être importé", - "action": { - "import": "Confirmer l'importation et continuer" - }, - "entities": { - "apps": "Applications", - "boards": "Tableaux de bord", - "integrations": "Intégrations", - "credentialUsers": "Utilisateurs identifiés" - } - }, - "tokenModal": { - "title": "Saisir le jeton d'importation", - "field": { - "token": { - "label": "Jeton", - "description": "Entrez le jeton d'importation de votre instance précédente" - } - }, - "notification": { - "error": { - "title": "Jeton non valide", - "message": "Le jeton que vous avez saisi est invalide" - } - } - } - }, - "user": { - "title": "Administrateur", - "subtitle": "Spécifiez les informations d'identification pour votre compte d'administrateur.", - "notification": { - "success": { - "title": "Utilisateur créé", - "message": "L’utilisateur a été créé avec succès" - }, - "error": { - "title": "La création de l'utilisateur a échoué" - } - } - }, - "group": { - "title": "Groupe externe", - "subtitle": "Spécifie le groupe à utiliser pour les utilisateurs externes.", - "form": { - "name": { - "label": "Nom du groupe", - "description": "Le nom doit correspondre au groupe administrateur du fournisseur externe" - } - } - }, - "settings": { - "title": "Paramètres", - "subtitle": "Configurer les paramètres du serveur." - }, - "integrations": { - "title": "Intégrations", - "subtitle": "Sélectionnez les services que vous utilisez.", - "description": "Choisissez les intégrations que vous souhaitez connecter. Nous allons configurer votre tableau de bord avec les widgets correspondants automatiquement.", - "action": { - "continue": "Continuer", - "skip": "Ignorer pour le moment" - }, - "configure": { - "progress": "{current} sur {total}", - "skip": "Ignorer cette intégration", - "finishing": "Configuration de votre tableau de bord...", - "error": { - "title": "Échec de la configuration", - "message": "Une erreur s'est produite lors de la configuration de votre tableau de bord. Veuillez réessayer." - }, - "helpers": { - "tooltip": "Aides pour les URL", - "description": "Ces aides essaient de deviner l'URL de vos services. Si cela ne fonctionne pas, tapez simplement l'adresse manuellement." - } - }, - "urlTemplate": { - "label": "URL de base", - "placeholder": "homelab.local ou 192.168.1.1", - "hint": "Entrez votre domaine de base ou votre IP pour le remplissage automatique des URL d'intégration", - "warning": "Définir votre URL de base maintenant remplira automatiquement toutes les URL d'intégration, ce qui vous fera gagner du temps lors de la configuration", - "preview": "ex. {example}", - "mode": { - "subdomain": "Sous-domaine", - "hostPort": "Hôte:Port" - } - }, - "docker": { - "detected": "Docker a détecté {count} services", - "detectedBreakdown": "Intégrations {integrations} · Applications {apps}", - "summary": "Les applications {apps} ont été détectées par Homarr, dont {integrations} ont des intégrations intégrées", - "appBadge": "App", - "appSectionLabel": "Applications à ajouter au tableau de bord ({count} sélectionné)", - "scanning": "Analyse de Docker...", - "scanningDescription": "Recherche de conteneurs en cours d'exécution sur votre système" - } - }, - "finish": { - "title": "Terminer la configuration", - "subtitle": "Vous êtes prêt !", - "description": "Vous avez terminé avec succès le processus d'installation. Vous pouvez maintenant commencer à utiliser Homarr. Sélectionnez votre prochaine action :", - "action": { - "goToBoard": "Accéder au tableau {name}", - "createBoard": "Créez votre premier tableau", - "inviteUser": "Inviter d'autres utilisateurs", - "docs": "Lire la documentation" - } - } - }, - "backToStart": "Retour au début" - }, + "init": {}, "user": { "title": "Utilisateurs", "name": "Utilisateur", @@ -4042,62 +3893,6 @@ "message": "Vous avez des modifications non enregistrées, êtes-vous sûr(e) de vouloir quitter ?" } }, - "oldImport": { - "label": "Importer depuis homarr avant la version 1.0.0", - "notification": { - "success": { - "title": "Importation réussie", - "message": "Le tableau a été importé avec succès" - }, - "error": { - "title": "Échec de l'importation", - "message": "Le tableau n'a pas pu être importé, vérifiez les logs pour plus de détails" - } - }, - "form": { - "file": { - "label": "Sélectionnez un fichier JSON", - "invalidError": "Fichier de configuration invalide" - }, - "apps": { - "label": "Applications", - "avoidDuplicates": { - "label": "Éviter les doublons", - "description": "Ignore les applications où une application avec le même href existe déjà" - }, - "onlyImportApps": { - "label": "Importer uniquement les applications", - "description": "Ajoute uniquement les applications, le tableau doit être recréé manuellement" - } - }, - "name": { - "label": "Nom du tableau" - }, - "screenSize": { - "label": "Taille de l'écran", - "description": "Dans les versions antérieures à 1,0 il existait trois modes différents, de sorte que vous pouviez choisir le nombre de colonnes pour chaque taille d'écran.", - "option": { - "sm": "Petite", - "md": "Moyenne", - "lg": "Grande" - } - }, - "sidebarBehavior": { - "label": "Comportement de la barre latérale", - "description": "Les barres latérales ont été enlevées en 1.0, vous pouvez sélectionner ce qui doit se passer avec les éléments à l'intérieur.", - "option": { - "lastSection": { - "label": "Dernière section", - "description": "La barre latérale sera affichée sous la dernière section" - }, - "removeItems": { - "label": "Retirer des éléments", - "description": "Les éléments contenus dans la barre latérale seront supprimés" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Créer une application à la volée", @@ -5708,9 +5503,6 @@ "newBoard": { "label": "Créer un nouveau tableau" }, - "importBoard": { - "label": "Importer un tableau" - }, "newApp": { "label": "Créer une nouvelle application" }, diff --git a/packages/translation/src/lang/he.json b/packages/translation/src/lang/he.json index 089037b82..8bcefbe83 100644 --- a/packages/translation/src/lang/he.json +++ b/packages/translation/src/lang/he.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "ברוכים הבאים ל- Homarr", - "subtitle": "בוא נתחיל בהגדרת ה-Homarr שלך.", - "description": "כדי להתחיל, בחר כיצד ברצונך להגדיר את ה-Homarr שלך.", - "action": { - "scratch": "התחל מאפס", - "importOldmarr": "יבוא מ-Homarr לפני 1.0", - "restoreSqlite": "", - "restoreSqliteDescription": "", - "backToOptions": "" - } - }, - "import": { - "title": "ייבוא נתונים", - "subtitle": "אתה יכול לייבא נתונים מ- Homarr קיים.", - "dropzone": { - "title": "גרור את קובץ ה-zip לכאן או לחץ כדי לחפש", - "description": "ה-zip שהועלה יעובד ותוכל לבחור מה אתה רוצה לייבא" - }, - "fileInfo": { - "action": { - "change": "שנה קובץ" - } - }, - "importSettings": { - "title": "הגדרות ייבוא", - "description": "הגדר את התנהגות הייבוא" - }, - "boardSelection": { - "title": "נמצאו {count} לוחות", - "description": "בחר את כל הלוחות עם הגודל שברצונך לייבא", - "action": { - "selectAll": "בחר הכל", - "unselectAll": "בטל את הבחירה בכולם" - } - }, - "summary": { - "title": "סיכום ייבוא", - "description": "בסיכום למטה תוכלו לראות מה ייבא", - "action": { - "import": "אשר את הייבוא והמשך" - }, - "entities": { - "apps": "אפליקציות", - "boards": "לוחות", - "integrations": "אינטגרציות", - "credentialUsers": "משתמשים מאושרים" - } - }, - "tokenModal": { - "title": "הזן אסימון ייבוא", - "field": { - "token": { - "label": "טוקן", - "description": "הזן את אסימון הייבוא המוצג ב- Homarr הקודם שלך" - } - }, - "notification": { - "error": { - "title": "אסימון לא חוקי", - "message": "האסימון שהזנת אינו חוקי" - } - } - } - }, - "user": { - "title": "משתמש מנהל", - "subtitle": "ציין את האישורים עבור משתמש המנהל שלך.", - "notification": { - "success": { - "title": "המשתמש נוצר", - "message": "המשתמש נוצר בהצלחה" - }, - "error": { - "title": "יצירת המשתמש נכשלה" - } - } - }, - "group": { - "title": "קבוצה חיצונית", - "subtitle": "ציין את הקבוצה שבה יש להשתמש עבור משתמשים חיצוניים.", - "form": { - "name": { - "label": "שם הקבוצה", - "description": "השם צריך להתאים לקבוצת הניהול של הספק החיצוני" - } - } - }, - "settings": { - "title": "הגדרות", - "subtitle": "קבע את הגדרות השרת." - }, - "integrations": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "continue": "", - "skip": "" - }, - "configure": { - "progress": "", - "skip": "", - "finishing": "", - "error": { - "title": "", - "message": "" - }, - "helpers": { - "tooltip": "", - "description": "" - } - }, - "urlTemplate": { - "label": "", - "placeholder": "", - "hint": "", - "warning": "", - "preview": "", - "mode": { - "subdomain": "", - "hostPort": "" - } - }, - "docker": { - "detected": "", - "detectedBreakdown": "", - "summary": "", - "appBadge": "", - "appSectionLabel": "", - "scanning": "", - "scanningDescription": "" - } - }, - "finish": { - "title": "סיים את ההגדרה", - "subtitle": "המערכת מוכנה לפעולה!", - "description": "השלמת בהצלחה את תהליך ההגדרה. כעת תוכל להתחיל להשתמש ב-Homarr. בחר את הפעולה הבאה שלך:", - "action": { - "goToBoard": "עבור ללוח {name}", - "createBoard": "צור את הלוח הראשון שלך", - "inviteUser": "הזמן משתמשים אחרים", - "docs": "קרא את הדוקומנטציה" - } - } - }, - "backToStart": "חזרה להתחלה" - }, + "init": {}, "user": { "title": "משתמשים", "name": "משתמש", @@ -3989,62 +3840,6 @@ "message": "יש לך שינויים שלא נשמרו, האם אתה בטוח שברצונך לעזוב?" } }, - "oldImport": { - "label": "יבוא מ- homarr לפני 1.0.0", - "notification": { - "success": { - "title": "הייבוא הצליח", - "message": "הלוח יובא בהצלחה" - }, - "error": { - "title": "הייבוא נכשל", - "message": "לא ניתן לייבא את הלוח, בדוק את לוגים לפרטים נוספים" - } - }, - "form": { - "file": { - "label": "בחר קובץ JSON", - "invalidError": "קובץ תצורה לא חוקי" - }, - "apps": { - "label": "אפליקציות", - "avoidDuplicates": { - "label": "הימנע מכפילויות", - "description": "מתעלם מאפליקציות שבהן כבר קיימת אפליקציה עם אותה כתובת רשת" - }, - "onlyImportApps": { - "label": "ייבוא רק אפליקציות", - "description": "רק מוסיף את האפליקציות, יש ליצור מחדש את הלוח באופן ידני" - } - }, - "name": { - "label": "שם הלוח" - }, - "screenSize": { - "label": "גודל מסך", - "description": "בגרסאות שלפני 1.0 היו קיימים שלושה מצבים שונים, כך שניתן היה לבחור את כמות העמודות עבור כל גודל מסך.", - "option": { - "sm": "קטן", - "md": "בינוני", - "lg": "גדול" - } - }, - "sidebarBehavior": { - "label": "התנהגות סרגל הצד", - "description": "סרגלי צד הוסרו ב- 1.0, אתה יכול לבחור מה צריך לקרות עם הפריטים שבתוכם.", - "option": { - "lastSection": { - "label": "החלק האחרון", - "description": "סרגל צד יוצג מתחת לקטע האחרון" - }, - "removeItems": { - "label": "הסרת פריטים", - "description": "פריטים הכלולים בסרגל הצד יוסרו" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "צור אפליקציה תוך כדי תנועה", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "יצירת לוח חדש" }, - "importBoard": { - "label": "ייבוא לוח" - }, "newApp": { "label": "יצירת אפליקציה חדשה" }, diff --git a/packages/translation/src/lang/hr.json b/packages/translation/src/lang/hr.json index 62ea41156..7bee6e38b 100644 --- a/packages/translation/src/lang/hr.json +++ b/packages/translation/src/lang/hr.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "scratch": "", - "importOldmarr": "", - "restoreSqlite": "Vrati iz SQLite sigurnosne kopije", - "restoreSqliteDescription": "Prenesite cjelovitu SQLite sigurnosnu kopiju baze podataka iz prethodne Homarr instance. To će vratiti sve ploče, korisnike, aplikacije, integracije i postavke.", - "backToOptions": "← Natrag na opcije postavljanja" - } - }, - "import": { - "title": "", - "subtitle": "", - "dropzone": { - "title": "", - "description": "" - }, - "fileInfo": { - "action": { - "change": "" - } - }, - "importSettings": { - "title": "", - "description": "" - }, - "boardSelection": { - "title": "", - "description": "", - "action": { - "selectAll": "", - "unselectAll": "" - } - }, - "summary": { - "title": "", - "description": "", - "action": { - "import": "" - }, - "entities": { - "apps": "aplikacije", - "boards": "Daske", - "integrations": "", - "credentialUsers": "" - } - }, - "tokenModal": { - "title": "", - "field": { - "token": { - "label": "Znak", - "description": "" - } - }, - "notification": { - "error": { - "title": "", - "message": "" - } - } - } - }, - "user": { - "title": "", - "subtitle": "", - "notification": { - "success": { - "title": "", - "message": "" - }, - "error": { - "title": "" - } - } - }, - "group": { - "title": "", - "subtitle": "", - "form": { - "name": { - "label": "", - "description": "" - } - } - }, - "settings": { - "title": "Postavke", - "subtitle": "" - }, - "integrations": { - "title": "Integracije", - "subtitle": "Odaberite usluge koje koristite.", - "description": "Odaberite integracije koje želite povezati. Automatski ćemo postaviti vašu ploču s odgovarajućim widgetima.", - "action": { - "continue": "Nastavi", - "skip": "Preskoči za sada" - }, - "configure": { - "progress": "{current} od {total}", - "skip": "Preskoči ovu integraciju", - "finishing": "Postavljanje vaše ploče...", - "error": { - "title": "Postavljanje nije uspjelo", - "message": "Nešto je pošlo po zlu prilikom postavljanja vaše ploče. Pokušajte ponovno." - }, - "helpers": { - "tooltip": "Pomagači URL-a", - "description": "Ovi pomagači pokušavaju pogoditi URL za vaše usluge. Ako ne radi, samo ručno upišite adresu." - } - }, - "urlTemplate": { - "label": "Osnovni URL", - "placeholder": "homelab.local ili 192.168.1.1", - "hint": "Unesite svoju osnovnu domenu ili IP za automatsko popunjavanje URL-ova integracija", - "warning": "Postavljanjem osnovnog URL-a sada automatski će ispuniti sve URL-ove integracija, uštedjet ćete vrijeme tijekom postavljanja", - "preview": "npr. {example}", - "mode": { - "subdomain": "Poddomena", - "hostPort": "Računalo:Port" - } - }, - "docker": { - "detected": "Docker je otkrio {count} usluga", - "detectedBreakdown": "{integrations} integracija · {apps} aplikacija", - "summary": "Homarr je otkrio {apps} aplikacija, {integrations} od kojih imaju ugrađene integracije", - "appBadge": "Aplikacija", - "appSectionLabel": "Aplikacije za dodavanje na ploču ({count} odabrano)", - "scanning": "Skeniranje Dockera...", - "scanningDescription": "Traženje pokrenutih kontejnera na vašem sustavu" - } - }, - "finish": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "goToBoard": "", - "createBoard": "", - "inviteUser": "", - "docs": "" - } - } - }, - "backToStart": "" - }, + "init": {}, "user": { "title": "Korisnici", "name": "Korisnik", @@ -3989,62 +3840,6 @@ "message": "Imate nespremljene promjene, jeste li sigurni da želite otići?" } }, - "oldImport": { - "label": "Uvoz iz homarr prije 1.0.0", - "notification": { - "success": { - "title": "Uvoz uspješan", - "message": "Ploča je uspješno uvezena" - }, - "error": { - "title": "Uvoz nije uspio", - "message": "Ploču nije bilo moguće uvesti, provjerite zapise za više detalja" - } - }, - "form": { - "file": { - "label": "Odaberite JSON datoteku", - "invalidError": "Nevažeća konfiguracijska datoteka" - }, - "apps": { - "label": "aplikacije", - "avoidDuplicates": { - "label": "Izbjegni duplikate", - "description": "Zanemaruje aplikacije kod kojih već postoji aplikacija s istim href-om" - }, - "onlyImportApps": { - "label": "Uvezi samo aplikacije", - "description": "Dodaje samo aplikacije, ploču treba ručno rekreirati" - } - }, - "name": { - "label": "Naziv ploče" - }, - "screenSize": { - "label": "Veličina zaslona", - "description": "", - "option": { - "sm": "Mali", - "md": "Srednji", - "lg": "velika" - } - }, - "sidebarBehavior": { - "label": "Ponašanje bočne trake", - "description": "Bočne trake su uklonjene u 1.0, možete odabrati što bi se trebalo dogoditi sa stavkama unutar njih.", - "option": { - "lastSection": { - "label": "Posljednji odjeljak", - "description": "Bočna traka će biti prikazana ispod posljednjeg odjeljka" - }, - "removeItems": { - "label": "Ukloni stavke", - "description": "Stavke unutar bočne trake bit će uklonjene" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "Stvori novu ploču" }, - "importBoard": { - "label": "Uvezi ploču" - }, "newApp": { "label": "Stvori novu aplikaciju" }, diff --git a/packages/translation/src/lang/hu.json b/packages/translation/src/lang/hu.json index fd5a5cc48..164040ba8 100644 --- a/packages/translation/src/lang/hu.json +++ b/packages/translation/src/lang/hu.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Üdvözöljük a Homarrban", - "subtitle": "Kezdjük el a Homarr-példány beállítását.", - "description": "A kezdéshez válassza ki, hogyan szeretné beállítani a Homarr-példányt.", - "action": { - "scratch": "Kezdjük a legelejétől", - "importOldmarr": "Importálás Homarr 1.0 előtti verzióból", - "restoreSqlite": "Visszaállítás SQLite-mentésből", - "restoreSqliteDescription": "Tölts fel egy teljes SQLite adatbázis biztonsági mentést egy korábbi Homarr példányból. Ez visszaállítja az összes táblát, felhasználót, alkalmazást, integrációt és beállítást.", - "backToOptions": "← Vissza a beállítási lehetőségekhez" - } - }, - "import": { - "title": "Importálás", - "subtitle": "Importálhat adatokat egy meglévő Homarr-példányból.", - "dropzone": { - "title": "Húzza ide a zip fájlt, vagy kattintson a böngészéshez", - "description": "A feltöltött zip fájlt feldolgozzuk, és kiválaszthatja, hogy mit szeretne importálni" - }, - "fileInfo": { - "action": { - "change": "Fájl módosítása" - } - }, - "importSettings": { - "title": "Beállítások importálása", - "description": "Az importálási viselkedés konfigurálása" - }, - "boardSelection": { - "title": "Talált {count} táblák", - "description": "Válassza ki az összes olyan méretű táblát, amelyet importálni szeretne", - "action": { - "selectAll": "Összes kijelölése", - "unselectAll": "Kijelölés megszüntetése" - } - }, - "summary": { - "title": "Import összefoglaló", - "description": "Az alábbi összefoglalóban láthatja, hogy mi lesz importálva", - "action": { - "import": "Importálás megerősítése és folytatás" - }, - "entities": { - "apps": "Alkalmazások", - "boards": "Táblák", - "integrations": "Integrációk", - "credentialUsers": "Hitelesített felhasználók" - } - }, - "tokenModal": { - "title": "Import token megadása", - "field": { - "token": { - "label": "Token", - "description": "Írja be a megjelenített import tokent az előző homarr példányból" - } - }, - "notification": { - "error": { - "title": "Érvénytelen token", - "message": "A megadott token érvénytelen" - } - } - } - }, - "user": { - "title": "Admin felhasználó", - "subtitle": "Adja meg a rendszergazda felhasználó hitelesítő adatait.", - "notification": { - "success": { - "title": "Létrejött a felhasználó", - "message": "A felhasználót sikeresen létrehoztuk" - }, - "error": { - "title": "A felhasználó létrehozása sikertelen" - } - } - }, - "group": { - "title": "Külső csoport", - "subtitle": "Adja meg a külső felhasználók számára használandó csoportot.", - "form": { - "name": { - "label": "Csoportnév", - "description": "A névnek meg kell egyeznie a külső szolgáltató admin csoportjával" - } - } - }, - "settings": { - "title": "Beállítások", - "subtitle": "Szerver beállítások konfigurálása." - }, - "integrations": { - "title": "Integrációk", - "subtitle": "Válaszd ki a használt szolgáltatásokat.", - "description": "Válaszd ki a csatlakoztatni kívánt integrációkat. Automatikusan beállítjuk az irányítópultot a megfelelő widgetekkel.", - "action": { - "continue": "Folytatás", - "skip": "Kihagyás most" - }, - "configure": { - "progress": "{current} / {total}", - "skip": "Integráció kihagyása", - "finishing": "Irányítópult beállítása...", - "error": { - "title": "A beállítás sikertelen", - "message": "Hiba történt az irányítópult beállítása közben. Próbáld újra." - }, - "helpers": { - "tooltip": "URL segítők", - "description": "Ezek a segítők megpróbálják kitalálni a szolgáltatásaid URL-jét. Ha nem működik, írd be manuálisan a címet." - } - }, - "urlTemplate": { - "label": "Alap URL", - "placeholder": "homelab.local vagy 192.168.1.1", - "hint": "Add meg az alap domain-t vagy IP-t az integrációs URL-ek automatikus kitöltéséhez", - "warning": "Az alap URL megadásával most automatikusan kitöltődik az összes integrációs URL, időt takarítva meg a beállítás során", - "preview": "pl. {example}", - "mode": { - "subdomain": "Aldomain", - "hostPort": "Hoszt:Port" - } - }, - "docker": { - "detected": "A Docker {count} szolgáltatást észlelt", - "detectedBreakdown": "{integrations} integráció · {apps} alkalmazás", - "summary": "A Homarr {apps} alkalmazást észlelt, ebből {integrations} beépített integrációval rendelkezik", - "appBadge": "Alkalmazás", - "appSectionLabel": "Irányítópultra adandó alkalmazások ({count} kiválasztva)", - "scanning": "Docker szkennelése...", - "scanningDescription": "Futó konténerek keresése a rendszeren" - } - }, - "finish": { - "title": "Beállítás befejezése", - "subtitle": "Készen állsz!", - "description": "Sikeresen befejezte a beállítási folyamatot. Most már használhatja a Homarr-t. Válassza ki a következő műveletet:", - "action": { - "goToBoard": "Tovább a {name} oldalra", - "createBoard": "Az első tábla létrehozása", - "inviteUser": "Más felhasználók meghívása", - "docs": "Olvassa el a dokumentációt" - } - } - }, - "backToStart": "Vissza a starthoz" - }, + "init": {}, "user": { "title": "Felhasználók", "name": "Felhasználó", @@ -3989,62 +3840,6 @@ "message": "Nem mentett változtatásai vannak, biztosan el akarja hagyni az oldalt?" } }, - "oldImport": { - "label": "Importálás a homarr 1.0.0 előtti verzióból", - "notification": { - "success": { - "title": "Sikeres importálás", - "message": "A tábla sikeresen importálva" - }, - "error": { - "title": "Importálás nem sikerült", - "message": "Táblát nem sikerült importálni; további részletekért nézd meg a naplófájlokat" - } - }, - "form": { - "file": { - "label": "JSON fájl kiválasztása", - "invalidError": "Érvénytelen konfigurációs fájl" - }, - "apps": { - "label": "Alkalmazások", - "avoidDuplicates": { - "label": "Kerülje az ismétlődéseket", - "description": "Nem veszi figyelembe azokat az alkalmazásokat, amelyeknél már létezik azonos href-vel rendelkező alkalmazás" - }, - "onlyImportApps": { - "label": "Csak alkalmazások importálása", - "description": "Csak az alkalmazásokat adja hozzá, a táblát manuálisan kell újra létrehozni" - } - }, - "name": { - "label": "Tábla neve" - }, - "screenSize": { - "label": "Képernyő méret", - "description": "Az 1.0 előtti verziókban három különböző mód létezett, így minden képernyőmérethez be tudtad állítani az oszlopok számát.", - "option": { - "sm": "Kicsi", - "md": "Közepes", - "lg": "Nagy" - } - }, - "sidebarBehavior": { - "label": "Oldalsáv viselkedés", - "description": "Az 1.0 verzióban az oldalsávok el lettek távolítva, kiválaszthatod, mi történjen a bennük lévő elemekkel.", - "option": { - "lastSection": { - "label": "Utolsó szakasz", - "description": "Az oldalsáv az utolsó szakasz alatt jelenik meg" - }, - "removeItems": { - "label": "Elemek eltávolítása", - "description": "Az oldalsávban lévő elemek el lesznek távolítva" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Alkalmazás létrehozása rögtön", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "Új tábla létrehozása" }, - "importBoard": { - "label": "Tábla importálása" - }, "newApp": { "label": "Új alkalmazás létrehozása" }, diff --git a/packages/translation/src/lang/it.json b/packages/translation/src/lang/it.json index 64fc49fa1..f64f493f2 100644 --- a/packages/translation/src/lang/it.json +++ b/packages/translation/src/lang/it.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Ti diamo il benvenuto su Homarr", - "subtitle": "Iniziamo con la creazione della tua istanza di Homarr.", - "description": "Per iniziare, seleziona come vuoi configurare la tua istanza di Homarr.", - "action": { - "scratch": "Inizia da zero", - "importOldmarr": "Importa da Homarr prima della 1.0", - "restoreSqlite": "Ripristina da backup SQLite", - "restoreSqliteDescription": "Carica un backup completo del database SQLite da una precedente istanza di Homarr. Questo ripristinerà tutte le board, gli utenti, le app, le integrazioni e le impostazioni.", - "backToOptions": "← Torna alle opzioni di configurazione" - } - }, - "import": { - "title": "Importa dati", - "subtitle": "Puoi importare dati da un'istanza Homarr esistente.", - "dropzone": { - "title": "Trascina il file zip qui o fai clic per sfogliare", - "description": "Lo zip caricato sarà elaborato e sarai in grado di selezionare quello che vuoi importare" - }, - "fileInfo": { - "action": { - "change": "Cambia file" - } - }, - "importSettings": { - "title": "Importa impostazioni", - "description": "Configura le azioni d'importazione" - }, - "boardSelection": { - "title": "Trovate {count} board", - "description": "Scegli tutte le board con la dimensione che vuoi importare", - "action": { - "selectAll": "Seleziona tutto", - "unselectAll": "Deseleziona tutto" - } - }, - "summary": { - "title": "Riepilogo", - "description": "Nel riepilogo sottostante puoi vedere cosa verrà importato", - "action": { - "import": "Conferma importazione e continua" - }, - "entities": { - "apps": "Applicazioni", - "boards": "Board", - "integrations": "Integrazioni", - "credentialUsers": "Credential users" - } - }, - "tokenModal": { - "title": "Inserisci il token d'importazione", - "field": { - "token": { - "label": "Token", - "description": "Inserisci il token d'importazione mostrato dalla tua precedente istanza homarr" - } - }, - "notification": { - "error": { - "title": "Token non valido", - "message": "Token inserito non valido" - } - } - } - }, - "user": { - "title": "Amministratore", - "subtitle": "Specifica le credenziali per l'amministratore.", - "notification": { - "success": { - "title": "Utente creato", - "message": "L'utente è stato creato correttamente" - }, - "error": { - "title": "Creazione dell'utente non riuscita" - } - } - }, - "group": { - "title": "Gruppo esterno", - "subtitle": "Specifica il gruppo da utilizzare per gli utenti esterni.", - "form": { - "name": { - "label": "Nome gruppo", - "description": "Il nome deve corrispondere al gruppo admin del provider esterno" - } - } - }, - "settings": { - "title": "Impostazioni", - "subtitle": "Configura le impostazioni del server." - }, - "integrations": { - "title": "Integrazioni", - "subtitle": "Seleziona i servizi che utilizzi.", - "description": "Scegli le integrazioni che desideri connettere. Configureremo automaticamente la tua dashboard con i widget corrispondenti.", - "action": { - "continue": "Continua", - "skip": "Salta per ora" - }, - "configure": { - "progress": "{current} di {total}", - "skip": "Salta questa integrazione", - "finishing": "Configurazione della tua dashboard in corso...", - "error": { - "title": "Configurazione non riuscita", - "message": "Qualcosa è andato storto durante la configurazione della tua dashboard. Riprova." - }, - "helpers": { - "tooltip": "Helper URL", - "description": "Questi helper cercano di indovinare l'URL dei tuoi servizi. Se non funziona, inserisci l'indirizzo manualmente." - } - }, - "urlTemplate": { - "label": "Url di base", - "placeholder": "homelab.local o 192.168.1.1", - "hint": "Inserisci il tuo dominio di base o IP per compilare automaticamente gli URL delle integrazioni", - "warning": "Impostando ora il tuo URL di base verrai aiutato a compilare automaticamente tutti gli URL delle integrazioni, risparmiando tempo durante la configurazione", - "preview": "es. {example}", - "mode": { - "subdomain": "Sottodominio", - "hostPort": "Host:Porta" - } - }, - "docker": { - "detected": "Docker ha rilevato {count} servizi", - "detectedBreakdown": "{integrations} integrazioni · {apps} app", - "summary": "Homarr ha rilevato {apps} app, {integrations} delle quali hanno integrazioni integrate", - "appBadge": "App", - "appSectionLabel": "App da aggiungere alla dashboard ({count} selezionate)", - "scanning": "Scansione Docker...", - "scanningDescription": "Ricerca di container in esecuzione sul sistema in corso" - } - }, - "finish": { - "title": "Termina configurazione", - "subtitle": "È tutto pronto per iniziare!", - "description": "Hai completato con successo la configurazione. Ora puoi iniziare a usare Homarr. Seleziona la prossima azione:", - "action": { - "goToBoard": "Vai alla board: {name}", - "createBoard": "Crea la tua prima board", - "inviteUser": "Invita altri utenti", - "docs": "Leggi la documentazione" - } - } - }, - "backToStart": "Torna all'inizio" - }, + "init": {}, "user": { "title": "Utenti", "name": "Utente", @@ -4058,62 +3909,6 @@ "message": "Ci sono modifiche non salvate. Vuoi davvero uscire?" } }, - "oldImport": { - "label": "Importa da homarr prima della versione 1.0.0", - "notification": { - "success": { - "title": "Importazione conclusa con successo", - "message": "La board è stata importata con successo" - }, - "error": { - "title": "Impossibile importare", - "message": "La board non può essere importata, controlla i log per ulteriori dettagli" - } - }, - "form": { - "file": { - "label": "Seleziona file JSON", - "invalidError": "File di configurazione non valido" - }, - "apps": { - "label": "Applicazioni", - "avoidDuplicates": { - "label": "Evita i duplicati", - "description": "Ignora le app in cui esiste già un'app con lo stesso href" - }, - "onlyImportApps": { - "label": "Importa solo app", - "description": "Aggiunge solo le app, il board deve essere ricreato manualmente" - } - }, - "name": { - "label": "Nome della board" - }, - "screenSize": { - "label": "Dimensione dello schermo", - "description": "Nelle versioni precedenti alla 1.0 esistevano tre modalità diverse, per cui era possibile scegliere il numero di colonne per ogni dimensione dello schermo.", - "option": { - "sm": "Piccolo", - "md": "Medio", - "lg": "Grande" - } - }, - "sidebarBehavior": { - "label": "Comportamento barra laterale", - "description": "Le barre laterali sono state rimosse nella versione 1.0, ora è possibile selezionare cosa fare con gli elementi al loro interno.", - "option": { - "lastSection": { - "label": "Ultima sezione", - "description": "La barra laterale verrà visualizzata sotto l'ultima sezione" - }, - "removeItems": { - "label": "Rimuovi elementi", - "description": "Gli elementi contenuti nella barra laterale verranno rimossi" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Crea app al volo", @@ -5737,9 +5532,6 @@ "newBoard": { "label": "Crea una nuova board" }, - "importBoard": { - "label": "Importa una board" - }, "newApp": { "label": "Crea una nuova app" }, diff --git a/packages/translation/src/lang/ja.json b/packages/translation/src/lang/ja.json index ad7047897..b7a072445 100644 --- a/packages/translation/src/lang/ja.json +++ b/packages/translation/src/lang/ja.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Homarrへようこそ", - "subtitle": "Homarrインスタンスの設定を始めましょう。", - "description": "始めるには、Homarrインスタンスの設定方法を選択してください。", - "action": { - "scratch": "自分でゼロから作成", - "importOldmarr": "1.0 より前の Homarr からインポート", - "restoreSqlite": "", - "restoreSqliteDescription": "", - "backToOptions": "" - } - }, - "import": { - "title": "データのインポート", - "subtitle": "既存のHomarrインスタンスからデータをインポートできます。", - "dropzone": { - "title": "ここにzipファイルをドラッグするか、クリックして参照してください", - "description": "アップロードされたzipファイルは処理され、インポートするものを選択することができます" - }, - "fileInfo": { - "action": { - "change": "ファイルを変更する" - } - }, - "importSettings": { - "title": "設定をインポート", - "description": "インポートの動作を設定" - }, - "boardSelection": { - "title": "{count} 個のボードを見つけました", - "description": "インポートしたいサイズのボードをすべて選択してください", - "action": { - "selectAll": "すべてを選択", - "unselectAll": "すべて選択解除" - } - }, - "summary": { - "title": "インポート結果", - "description": "以下のサマリーで、インポートする内容を確認できます", - "action": { - "import": "インポートを確認して続ける" - }, - "entities": { - "apps": "アプリ", - "boards": "ボード", - "integrations": "連携機能", - "credentialUsers": "認証情報ユーザー" - } - }, - "tokenModal": { - "title": "インポートトークンを入力してください", - "field": { - "token": { - "label": "トークン", - "description": "以前のhomarrインスタンスから表示されたインポートトークンを入力してください" - } - }, - "notification": { - "error": { - "title": "無効なトークン", - "message": "入力されたトークンは無効です" - } - } - } - }, - "user": { - "title": "管理者ユーザー", - "subtitle": "管理者ユーザーの認証情報を指定します。", - "notification": { - "success": { - "title": "ユーザーが作成されました", - "message": "ユーザーが正常に作成されました" - }, - "error": { - "title": "ユーザーの作成に失敗しました" - } - } - }, - "group": { - "title": "外部グループ", - "subtitle": "外部ユーザーに使用するグループを指定します。", - "form": { - "name": { - "label": "グループ名", - "description": "名前は外部プロバイダの管理者グループと一致する必要があります" - } - } - }, - "settings": { - "title": "設定", - "subtitle": "サーバーの設定。" - }, - "integrations": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "continue": "", - "skip": "" - }, - "configure": { - "progress": "", - "skip": "", - "finishing": "", - "error": { - "title": "", - "message": "" - }, - "helpers": { - "tooltip": "", - "description": "" - } - }, - "urlTemplate": { - "label": "", - "placeholder": "", - "hint": "", - "warning": "", - "preview": "", - "mode": { - "subdomain": "", - "hostPort": "" - } - }, - "docker": { - "detected": "", - "detectedBreakdown": "", - "summary": "", - "appBadge": "", - "appSectionLabel": "", - "scanning": "", - "scanningDescription": "" - } - }, - "finish": { - "title": "設定終了", - "subtitle": "準備ができています!", - "description": "セットアッププロセスが正常に完了しました。今すぐHomarrの使用を開始できます。次のアクションを選択してください:", - "action": { - "goToBoard": "{name} ボードへ移動", - "createBoard": "あなたの最初のボードを作成する", - "inviteUser": "他のユーザーを招待する", - "docs": "ドキュメントを読む" - } - } - }, - "backToStart": "最初に戻る" - }, + "init": {}, "user": { "title": "ユーザー", "name": "ユーザー", @@ -3989,62 +3840,6 @@ "message": "保存されていない変更があります。このページから移動してもいいですか?" } }, - "oldImport": { - "label": "1.0.0 より前の Homarr からインポート", - "notification": { - "success": { - "title": "インポート成功", - "message": "ボードは、正常にインポートされました" - }, - "error": { - "title": "インポート失敗", - "message": "ボードをインポートできませんでした。詳細はログを確認してください" - } - }, - "form": { - "file": { - "label": "JSON ファイルを選択", - "invalidError": "無効な設定ファイルです" - }, - "apps": { - "label": "アプリ", - "avoidDuplicates": { - "label": "重複の回避", - "description": "同じ href を持つアプリが既に存在するアプリを無視します" - }, - "onlyImportApps": { - "label": "アプリのみインポート", - "description": "アプリのみを追加します。ボードを手動で再作成する必要があります" - } - }, - "name": { - "label": "ボード名" - }, - "screenSize": { - "label": "画面サイズ", - "description": "1.0 以前のバージョンでは、3種類のモードが存在するため、画面サイズごとに列数を選択することができました。", - "option": { - "sm": "小", - "md": "中", - "lg": "大" - } - }, - "sidebarBehavior": { - "label": "サイドバーの動作", - "description": "サイドバーは 1.0 で削除され、内部のアイテムで何が起こるかを選択できます。", - "option": { - "lastSection": { - "label": "最後のセクション", - "description": "最後のセクションの下に、サイドバーが表示されます" - }, - "removeItems": { - "label": "アイテムを削除", - "description": "サイドバーに含まれるアイテムは、削除されます" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "即座にアプリを作成", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "新しいボードを作成する" }, - "importBoard": { - "label": "ボードをインポート" - }, "newApp": { "label": "新しいアプリを作成する" }, diff --git a/packages/translation/src/lang/ko.json b/packages/translation/src/lang/ko.json index 08c1da46f..c2ae435d2 100644 --- a/packages/translation/src/lang/ko.json +++ b/packages/translation/src/lang/ko.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Homarr에 오신 것을 환영합니다", - "subtitle": "Homarr 인스턴스 설정을 시작합니다.", - "description": "시작하기 전에, Homarr 인스턴스를 어떻게 설정할지 선택해주세요.", - "action": { - "scratch": "처음부터 시작하기", - "importOldmarr": "1.0 이전의 Homarr에서 불러오기", - "restoreSqlite": "SQLite 백업 복원", - "restoreSqliteDescription": "이전 Homarr 인스턴스로부터 전체 SQLite 데이터베이스 백업을 업로드합니다. 모든 보드, 사용자, 앱, 통합 및 설정이 복원됩니다.", - "backToOptions": "← 설정 옵션으로 돌아가기" - } - }, - "import": { - "title": "데이터 불러오기", - "subtitle": "기존 Homarr 인스턴스에서 데이터를 불러올 수 있습니다.", - "dropzone": { - "title": "zip 파일 드롭 또는 클릭해서 찾아보기", - "description": "업로드된 zip이 처리되어 가져올 항목을 선택할 수 있습니다" - }, - "fileInfo": { - "action": { - "change": "파일 변경" - } - }, - "importSettings": { - "title": "설정 불러오기", - "description": "가져오기 동작 설정" - }, - "boardSelection": { - "title": "{count}개의 보드 찾음", - "description": "가져올 보드의 크기를 모두 선택", - "action": { - "selectAll": "모두 선택", - "unselectAll": "모두 선택 해제" - } - }, - "summary": { - "title": "불러오기 요약", - "description": "아래 요약에서 가져올 항목을 확인할 수 있습니다", - "action": { - "import": "가져오기 확인 및 계속 진행" - }, - "entities": { - "apps": "앱", - "boards": "보드", - "integrations": "통합", - "credentialUsers": "자격 증명 사용자" - } - }, - "tokenModal": { - "title": "가져오기 토큰 입력", - "field": { - "token": { - "label": "토큰", - "description": "이전 Homarr 인스턴스에서 표시된 가져오기 토큰 입력" - } - }, - "notification": { - "error": { - "title": "유효하지 않은 토큰", - "message": "입력한 토큰이 유효하지 않습니다" - } - } - } - }, - "user": { - "title": "관리자", - "subtitle": "관리자의 계정 정보를 설정해주세요.", - "notification": { - "success": { - "title": "계정 생성됨", - "message": "계정이 성공적으로 생성되었습니다" - }, - "error": { - "title": "계정 생성 실패" - } - } - }, - "group": { - "title": "외부 그룹", - "subtitle": "외부 사용자가 사용할 그룹을 지정합니다.", - "form": { - "name": { - "label": "그룹 이름", - "description": "이름은 외부 제공자의 관리자 그룹과 일치해야 합니다" - } - } - }, - "settings": { - "title": "설정", - "subtitle": "서버 설정하기." - }, - "integrations": { - "title": "통합", - "subtitle": "사용할 서비스를 선택합니다.", - "description": "연결하려는 통합을 선택하십시오. 일치하는 위젯으로 대시보드를 자동 설정합니다.", - "action": { - "continue": "계속", - "skip": "지금은 건너뛰기" - }, - "configure": { - "progress": "{total}개 중 {current}개", - "skip": "이 통합 건너뛰기", - "finishing": "대시보드 설정 중...", - "error": { - "title": "설치 실패", - "message": "대시보드를 설정하는 중 문제가 발생했습니다. 다시 시도해 주십시오." - }, - "helpers": { - "tooltip": "URL 도우미", - "description": "서비스 URL을 추측합니다. 작동하지 않을 경우, 주소를 직접 입력해 주십시오." - } - }, - "urlTemplate": { - "label": "기본 URL", - "placeholder": "homelab.local 또는 192.168.1.1", - "hint": "기본 도메인 또는 IP를 입력하여 통합 URL 자동 채우기", - "warning": "지금 기본 URL을 설정하면 모든 통합 URL이 자동으로 채워져 설정 시간을 아낄 수 있습니다", - "preview": "예) {example}", - "mode": { - "subdomain": "서브도메인", - "hostPort": "호스트:포트" - } - }, - "docker": { - "detected": "{count}개 docker 서비스 감지", - "detectedBreakdown": "통합 {integrations}개, 앱 {apps}개", - "summary": "Homarr가 {apps}개 앱을 감지했으며, 그 중 {integrations}개는 통합 기능이 내장되어 있습니다", - "appBadge": "앱", - "appSectionLabel": "대시보드에 추가할 앱 ({count}개 선택)", - "scanning": "Docker 스캔 중...", - "scanningDescription": "시스템에서 실행 중인 컨테이너 찾기" - } - }, - "finish": { - "title": "설정 완료", - "subtitle": "모든 준비가 끝났습니다!", - "description": "설정 과정을 성공적으로 완료했습니다. 이제 Homarr을 사용할 수 있습니다. 다음 작업을 선택해주세요:", - "action": { - "goToBoard": "{name} 보드로 가기", - "createBoard": "첫 번째 보드 만들기", - "inviteUser": "다른 사용자 초대하기", - "docs": "문서 읽기" - } - } - }, - "backToStart": "시작으로 돌아가기" - }, + "init": {}, "user": { "title": "사용자", "name": "사용자", @@ -4043,62 +3894,6 @@ "message": "저장되지 않은 변경 사항이 있습니다. 나가시겠습니까?" } }, - "oldImport": { - "label": "1.0.0 이전의 Homarr에서 가져오기", - "notification": { - "success": { - "title": "가져오기 완료", - "message": "보드 가져오기가 완료되었습니다" - }, - "error": { - "title": "가져오기 실패", - "message": "보드를 가져올 수 없었습니다, 상세 내용은 로그를 확인해 주십시오" - } - }, - "form": { - "file": { - "label": "JSON 파일 선택", - "invalidError": "올바르지 않은 설정 파일" - }, - "apps": { - "label": "앱", - "avoidDuplicates": { - "label": "중복 방지", - "description": "동일한 href를 가진 앱에서는 가져오기 내용을 무시합니다" - }, - "onlyImportApps": { - "label": "앱만 가져오기", - "description": "앱만 추가합니다. 보드는 수동으로 다시 생성해야 합니다" - } - }, - "name": { - "label": "보드 이름" - }, - "screenSize": { - "label": "화면 크기", - "description": "1.0 이전 버전에서는 세 가지의 다른 모드가 존재했으므로 각 화면 크기에 맞는 열 수를 선택할 수 있었습니다.", - "option": { - "sm": "작음", - "md": "중간", - "lg": "큼" - } - }, - "sidebarBehavior": { - "label": "사이드바 행동", - "description": "사이드바는 1.0에서 제거되었으며, 그 안에 있던 항목을 어떻게 처리할지 선택할 수 있습니다.", - "option": { - "lastSection": { - "label": "마지막 섹션", - "description": "마지막 섹션 아래에 사이드바를 표시합니다" - }, - "removeItems": { - "label": "항목 제거", - "description": "사이드바 내에 있는 항목을 제거합니다" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "즉석 앱 생성", @@ -5721,9 +5516,6 @@ "newBoard": { "label": "새 보드 생성" }, - "importBoard": { - "label": "보드 가져오기" - }, "newApp": { "label": "새 앱 생성" }, diff --git a/packages/translation/src/lang/lt.json b/packages/translation/src/lang/lt.json index 62ff79725..d7dff35e0 100644 --- a/packages/translation/src/lang/lt.json +++ b/packages/translation/src/lang/lt.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "scratch": "", - "importOldmarr": "", - "restoreSqlite": "Atkurti iš SQLite atsarginės kopijos", - "restoreSqliteDescription": "Įkelkite pilną SQLite duomenų bazės atsarginę kopiją iš ankstesnio Homarr egzemplioriaus. Tai atkurs visas lentas, naudotojus, programėles, integracijas ir nustatymus.", - "backToOptions": "← Atgal į sąrankos parinktis" - } - }, - "import": { - "title": "", - "subtitle": "", - "dropzone": { - "title": "", - "description": "" - }, - "fileInfo": { - "action": { - "change": "Pakeisti failą" - } - }, - "importSettings": { - "title": "", - "description": "" - }, - "boardSelection": { - "title": "", - "description": "", - "action": { - "selectAll": "", - "unselectAll": "" - } - }, - "summary": { - "title": "", - "description": "", - "action": { - "import": "" - }, - "entities": { - "apps": "Programėlės", - "boards": "Lentos", - "integrations": "", - "credentialUsers": "" - } - }, - "tokenModal": { - "title": "", - "field": { - "token": { - "label": "Žetonas", - "description": "" - } - }, - "notification": { - "error": { - "title": "", - "message": "" - } - } - } - }, - "user": { - "title": "", - "subtitle": "", - "notification": { - "success": { - "title": "", - "message": "" - }, - "error": { - "title": "" - } - } - }, - "group": { - "title": "", - "subtitle": "", - "form": { - "name": { - "label": "", - "description": "" - } - } - }, - "settings": { - "title": "Nustatymai", - "subtitle": "" - }, - "integrations": { - "title": "Integracijos", - "subtitle": "Pasirinkite naudojamas paslaugas.", - "description": "Pasirinkite integracijas, kurias norite prijungti. Automatiškai sukonfigūruosime jūsų valdymo skydelį su atitinkamais valdikliais.", - "action": { - "continue": "Tęsti", - "skip": "Kol kas praleisti" - }, - "configure": { - "progress": "{current} iš {total}", - "skip": "Praleisti šią integraciją", - "finishing": "Konfigūruojamas valdymo skydelis...", - "error": { - "title": "Sąranka nepavyko", - "message": "Kažkas nepavyko konfigūruojant valdymo skydelį. Bandykite dar kartą." - }, - "helpers": { - "tooltip": "URL pagalbininkai", - "description": "Šie pagalbininkai bando atspėti jūsų paslaugų URL. Jei nepavyksta, tiesiog įveskite adresą rankiniu būdu." - } - }, - "urlTemplate": { - "label": "Bazinis URL", - "placeholder": "homelab.local arba 192.168.1.1", - "hint": "Įveskite pagrindinį domeną ar IP, kad automatiškai užpildytumėte integracijų URL", - "warning": "Nustatę bazinį URL dabar, automatiškai užpildysite visus integracijų URL, taip sutaupydami laiko diegimo metu", - "preview": "pvz., {example}", - "mode": { - "subdomain": "Subdomenas", - "hostPort": "Mazgas:Prievadas" - } - }, - "docker": { - "detected": "Docker aptiko {count} paslaugas", - "detectedBreakdown": "{integrations} integracijų · {apps} programėlių", - "summary": "Homarr aptiko {apps} programėlių, iš kurių {integrations} turi integracijas", - "appBadge": "Programėlė", - "appSectionLabel": "Programėlės, pridedamos į valdymo skydelį (pasirinkta {count})", - "scanning": "Nuskaitomas Docker...", - "scanningDescription": "Ieškoma veikiančių konteinerių jūsų sistemoje" - } - }, - "finish": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "goToBoard": "", - "createBoard": "", - "inviteUser": "", - "docs": "" - } - } - }, - "backToStart": "" - }, + "init": {}, "user": { "title": "Vartotojai", "name": "Vartotojas", @@ -3989,62 +3840,6 @@ "message": "Turite neišsaugotų pakeitimų, ar tikrai norite išeiti?" } }, - "oldImport": { - "label": "Importuoti iš homarr prieš 1.0.0", - "notification": { - "success": { - "title": "Importavimas sėkmingas", - "message": "Lenta sėkmingai importuota" - }, - "error": { - "title": "Importavimas nepavyko", - "message": "Lentos importuoti nepavyko, patikrinkite žurnalus dėl išsamesnės informacijos" - } - }, - "form": { - "file": { - "label": "Pasirinkite JSON failą", - "invalidError": "Netinkamas konfigūracijos failas" - }, - "apps": { - "label": "Programėlės", - "avoidDuplicates": { - "label": "Vengti dublikatų", - "description": "Nepaiso programėlių, kai jau yra programėlė su tokiu pat href" - }, - "onlyImportApps": { - "label": "Importuoti tik programėles", - "description": "Prideda tik programėles, lentą reikia perkurti rankiniu būdu" - } - }, - "name": { - "label": "Lentos pavadinimas" - }, - "screenSize": { - "label": "Ekrano dydis", - "description": "", - "option": { - "sm": "Mažas", - "md": "Vidutinis", - "lg": "Didelis" - } - }, - "sidebarBehavior": { - "label": "Šoninės juostos elgsena", - "description": "Šoninės juostos buvo pašalintos 1.0, galite pasirinkti, kas turėtų nutikti su jose esančiais elementais.", - "option": { - "lastSection": { - "label": "Paskutinė sekcija", - "description": "Šoninė juosta bus rodoma po paskutine sekcija" - }, - "removeItems": { - "label": "Pašalinti elementus", - "description": "Elementai, esantys šoninėje juostoje, bus pašalinti" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "" }, - "importBoard": { - "label": "" - }, "newApp": { "label": "" }, diff --git a/packages/translation/src/lang/lv.json b/packages/translation/src/lang/lv.json index 689d618a1..9293181b8 100644 --- a/packages/translation/src/lang/lv.json +++ b/packages/translation/src/lang/lv.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "scratch": "Sākt no jauna", - "importOldmarr": "", - "restoreSqlite": "SQLite rezerves kopija", - "restoreSqliteDescription": "Augšupielādējiet pilnu SQLite datu bāzes dublējumu no iepriekšējās Homarr instances. Tas atjaunos visus dēļus, lietotājus, lietotnes, integrācijas un iestatījumus.", - "backToOptions": "← Atpakaļ uz iestatīšanas opcijām" - } - }, - "import": { - "title": "Importēt datus", - "subtitle": "", - "dropzone": { - "title": "", - "description": "" - }, - "fileInfo": { - "action": { - "change": "" - } - }, - "importSettings": { - "title": "Importēt iestatījumus", - "description": "" - }, - "boardSelection": { - "title": "", - "description": "", - "action": { - "selectAll": "Atzīmēt visu", - "unselectAll": "" - } - }, - "summary": { - "title": "Importēšanas kopsavilkums", - "description": "", - "action": { - "import": "" - }, - "entities": { - "apps": "Lietotnes", - "boards": "Dēļi", - "integrations": "Integrācijas", - "credentialUsers": "" - } - }, - "tokenModal": { - "title": "", - "field": { - "token": { - "label": "Atslēga", - "description": "" - } - }, - "notification": { - "error": { - "title": "", - "message": "" - } - } - } - }, - "user": { - "title": "Administratora lietotājs", - "subtitle": "", - "notification": { - "success": { - "title": "Lietotājs izveidots", - "message": "" - }, - "error": { - "title": "Lietotāja izveide neizdevās" - } - } - }, - "group": { - "title": "", - "subtitle": "", - "form": { - "name": { - "label": "", - "description": "" - } - } - }, - "settings": { - "title": "Iestatījumi", - "subtitle": "" - }, - "integrations": { - "title": "Integrācijas", - "subtitle": "Atlasiet pakalpojumus, ko izmantojat.", - "description": "Izvēlieties integrācijas, ko vēlaties savienot. Mēs automātiski iestatīsim jūsu informācijas paneli ar atbilstošiem logrīkiem.", - "action": { - "continue": "Turpināt", - "skip": "Pagaidām izlaist šo soli" - }, - "configure": { - "progress": "{current} no {total}", - "skip": "Izlaist šo integrāciju", - "finishing": "Notiek informācijas paneļa iestatīšana...", - "error": { - "title": "Neizdevās iestatīt!", - "message": "Radās kļūda, iestatot jūsu informācijas paneli. Lūdzu, mēģiniet vēlreiz." - }, - "helpers": { - "tooltip": "URL palīgrīki", - "description": "Šie palīgrīki mēģina uzminēt jūsu pakalpojumu URL. Ja tas nedarbojas, vienkārši ievadiet adresi manuāli." - } - }, - "urlTemplate": { - "label": "Bāzes URL", - "placeholder": "homelab.local vai 192.168.1.1", - "hint": "Ievadiet savu bāzes domēnu vai IP, lai automātiski aizpildītu integrāciju URL", - "warning": "Bāzes URL iestatīšana tagad automātiski aizpildīs visus integrāciju URL, ietaupot laiku iestatīšanas laikā", - "preview": "piem., {example}", - "mode": { - "subdomain": "Apakšdomēns", - "hostPort": "Resursdators:Ports" - } - }, - "docker": { - "detected": "Docker atklāja {count} pakalpojumus", - "detectedBreakdown": "{integrations} integrācijas · {apps} lietotnes", - "summary": "Homarr atklāja {apps} lietotnes, {integrations} no tām ir iebūvētas integrācijas", - "appBadge": "Lietotne", - "appSectionLabel": "Pievienojamās lietotnes informācijas panelim ({count} atlasītas)", - "scanning": "Notiek Docker skenēšana...", - "scanningDescription": "Meklē darbojošos konteinerus jūsu sistēmā" - } - }, - "finish": { - "title": "Pabeigt Iestatīšanu", - "subtitle": "", - "description": "", - "action": { - "goToBoard": "", - "createBoard": "", - "inviteUser": "Ielūgt citus lietotājus", - "docs": "Lasīt dokumentāciju" - } - } - }, - "backToStart": "" - }, + "init": {}, "user": { "title": "Lietotāji", "name": "Lietotājs", @@ -3999,62 +3850,6 @@ "message": "" } }, - "oldImport": { - "label": "", - "notification": { - "success": { - "title": "", - "message": "" - }, - "error": { - "title": "", - "message": "" - } - }, - "form": { - "file": { - "label": "", - "invalidError": "" - }, - "apps": { - "label": "Lietotnes", - "avoidDuplicates": { - "label": "", - "description": "" - }, - "onlyImportApps": { - "label": "", - "description": "" - } - }, - "name": { - "label": "" - }, - "screenSize": { - "label": "", - "description": "", - "option": { - "sm": "Mazs", - "md": "Vidējs", - "lg": "Liels" - } - }, - "sidebarBehavior": { - "label": "", - "description": "", - "option": { - "lastSection": { - "label": "", - "description": "" - }, - "removeItems": { - "label": "", - "description": "" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "", @@ -5665,9 +5460,6 @@ "newBoard": { "label": "" }, - "importBoard": { - "label": "" - }, "newApp": { "label": "" }, diff --git a/packages/translation/src/lang/nl.json b/packages/translation/src/lang/nl.json index 152456f18..8f1e76c37 100644 --- a/packages/translation/src/lang/nl.json +++ b/packages/translation/src/lang/nl.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Welkom bij Homarr", - "subtitle": "Laten we beginnen met het opzetten van je Homarr instantie.", - "description": "Selecteer om te beginnen hoe je je Homarr instantie wilt instellen.", - "action": { - "scratch": "Vanaf nul beginnen", - "importOldmarr": "Importeren uit Homarr vóór 1.0", - "restoreSqlite": "Herstellen vanuit SQLite-back-up", - "restoreSqliteDescription": "Upload een volledige SQLite-databaseback-up van een vorige Homarr-instantie. Dit zal alle borden, gebruikers, apps, integraties en instellingen herstellen.", - "backToOptions": "← Terug naar installatie-opties" - } - }, - "import": { - "title": "Gegevens importeren", - "subtitle": "Je kunt gegevens importeren uit een bestaande Homarr instantie.", - "dropzone": { - "title": "Sleep het ZIP-bestand hierheen of klik om te bladeren", - "description": "De geüploade ZIP wordt verwerkt en je kunt selecteren wat je wilt importeren" - }, - "fileInfo": { - "action": { - "change": "Bestand veranderen" - } - }, - "importSettings": { - "title": "Instellingen importeren", - "description": "Het importgedrag configureren" - }, - "boardSelection": { - "title": "{count} borden gevonden", - "description": "Kies alle borden met hun grootte die je wilt importeren", - "action": { - "selectAll": "Alles selecteren", - "unselectAll": "Alles deselecteren" - } - }, - "summary": { - "title": "Overzicht importeren", - "description": "In het onderstaande overzicht kun je zien wat er wordt geïmporteerd", - "action": { - "import": "Import bevestigen en doorgaan" - }, - "entities": { - "apps": "Apps", - "boards": "Borden", - "integrations": "Integraties", - "credentialUsers": "Inloggegevens gebruikers" - } - }, - "tokenModal": { - "title": "Importeertoken invoeren", - "field": { - "token": { - "label": "Penning", - "description": "Voer het getoonde importeertoken van je vorige Homarr instantie in" - } - }, - "notification": { - "error": { - "title": "Ongeldig token", - "message": "Het ingevoerde token is ongeldig" - } - } - } - }, - "user": { - "title": "Beheerder gebruiker", - "subtitle": "Geef de inloggegevens op voor de administrator gebruiker.", - "notification": { - "success": { - "title": "Gebruiker aangemaakt", - "message": "De gebruiker is succesvol aangemaakt" - }, - "error": { - "title": "Gebruiker aanmaken mislukt" - } - } - }, - "group": { - "title": "Externe groep", - "subtitle": "Geef de groep op die moet worden gebruikt voor externe gebruikers.", - "form": { - "name": { - "label": "Groepsnaam", - "description": "De naam moet overeenkomen met de beheerdersgroep van de externe provider" - } - } - }, - "settings": { - "title": "Instellingen", - "subtitle": "Serverinstellingen configureren." - }, - "integrations": { - "title": "Integraties", - "subtitle": "Selecteer de services die je gebruikt.", - "description": "Kies de integraties die je wilt verbinden. We stellen je dashboard in met bijbehorende widgets.", - "action": { - "continue": "Doorgaan", - "skip": "Overslaan voor nu" - }, - "configure": { - "progress": "{current} van {total}", - "skip": "Deze integratie overslaan", - "finishing": "Je dashboard instellen...", - "error": { - "title": "Instellen mislukt", - "message": "Er is iets misgegaan bij het instellen van je dashboard. Probeer het opnieuw." - }, - "helpers": { - "tooltip": "URL-helpers", - "description": "Deze helpers proberen de URL voor je services te raden. Als het niet werkt, typ dan handmatig het adres." - } - }, - "urlTemplate": { - "label": "Basis-URL", - "placeholder": "homelab.local of 192.168.1.1", - "hint": "Voer je basisdomein of IP in om integratie-URL's automatisch in te vullen", - "warning": "Door nu je basis-URL in te stellen worden alle integratie-URL's automatisch ingevuld, wat je tijd bespaart tijdens de installatie", - "preview": "bijv. {example}", - "mode": { - "subdomain": "Subdomein", - "hostPort": "Host:Poort" - } - }, - "docker": { - "detected": "Docker heeft {count} services gedetecteerd", - "detectedBreakdown": "{integrations} integraties · {apps} apps", - "summary": "Homarr heeft {apps} apps gedetecteerd, waarvan {integrations} ingebouwde integraties hebben", - "appBadge": "App", - "appSectionLabel": "Apps om aan dashboard toe te voegen ({count} geselecteerd)", - "scanning": "Docker scannen...", - "scanningDescription": "Zoeken naar actieve containers op je systeem" - } - }, - "finish": { - "title": "Instellen voltooien", - "subtitle": "Je bent er klaar voor!", - "description": "Je hebt het installatieproces succesvol afgerond. Je kunt Homarr nu gaan gebruiken. Selecteer je volgende actie:", - "action": { - "goToBoard": "Ga naar {name} bord", - "createBoard": "Je eerste bord aanmaken", - "inviteUser": "Andere gebruikers uitnodigen", - "docs": "Lees de documentatie" - } - } - }, - "backToStart": "Terug naar start" - }, + "init": {}, "user": { "title": "Gebruikers", "name": "Gebruiker", @@ -3989,62 +3840,6 @@ "message": "Je hebt niet-opgeslagen wijzigingen, weet je zeker dat je weg wilt?" } }, - "oldImport": { - "label": "Importeren uit Homarr vóór 1.0.0", - "notification": { - "success": { - "title": "Succesvol geimporteerd", - "message": "Het bord is succesvol geïmporteerd" - }, - "error": { - "title": "Importeren mislukt", - "message": "Het bord kon niet worden geïmporteerd, controleer de logs voor meer informatie" - } - }, - "form": { - "file": { - "label": "JSON-bestand selecteren", - "invalidError": "Ongeldig configuratiebestand" - }, - "apps": { - "label": "Apps", - "avoidDuplicates": { - "label": "Duplicaten vermijden", - "description": "Negeert apps waar al een app met dezelfde href bestaat" - }, - "onlyImportApps": { - "label": "Alleen apps importeren", - "description": "Voegt alleen de apps toe, het bord moet handmatig opnieuw worden aangemaakt" - } - }, - "name": { - "label": "Bord naam" - }, - "screenSize": { - "label": "Schermgrootte", - "description": "In versies vóór 1.0 bestonden drie verschillende modi, zodat je het aantal kolommen voor elke schermgrootte kon kiezen.", - "option": { - "sm": "Klein", - "md": "Gemiddeld", - "lg": "Groot" - } - }, - "sidebarBehavior": { - "label": "Zijbalk-gedrag", - "description": "Zijbalken zijn verwijderd in 1.0, je kunt kiezen wat er moet gebeuren met de items erin.", - "option": { - "lastSection": { - "label": "Laatste sectie", - "description": "De zijbalk wordt onder de laatste sectie weergegeven" - }, - "removeItems": { - "label": "Items verwijderen", - "description": "Items in de zijbalk worden verwijderd" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "App onmiddellijk maken", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "Een nieuw bord aanmaken" }, - "importBoard": { - "label": "Een bord importeren" - }, "newApp": { "label": "Een nieuwe app aanmaken" }, diff --git a/packages/translation/src/lang/no.json b/packages/translation/src/lang/no.json index 514acbed3..143bfbcd8 100644 --- a/packages/translation/src/lang/no.json +++ b/packages/translation/src/lang/no.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Velkommen til Homarr", - "subtitle": "La oss begynne med å sette opp Homarr-forekomsten din.", - "description": "For å komme i gang, velg hvordan du vil sette opp din Homarr forekomst.", - "action": { - "scratch": "Start fra begynnelsen", - "importOldmarr": "Importer fra Homarr før 1.0", - "restoreSqlite": "Gjenopprett fra SQLite sikkerhetskopi", - "restoreSqliteDescription": "Last opp en full SQLite database sikkerhetskopi fra en tidligere Homarr instans. Dette vil gjenopprette alle tavler, brukere, apper, integrasjoner og innstillinger.", - "backToOptions": "" - } - }, - "import": { - "title": "Importer data", - "subtitle": "Du kan importere data fra en eksisterende Homarr-forekomst.", - "dropzone": { - "title": "Dra zip-filen hit eller klikk for å bla gjennom", - "description": "Den opplastede zip-filen vil bli behandlet og du kan velge hva du ønsker å importere" - }, - "fileInfo": { - "action": { - "change": "Endre fil" - } - }, - "importSettings": { - "title": "Importinnstillinger", - "description": "Konfigurer import oppførsel" - }, - "boardSelection": { - "title": "Fant {count} tavler", - "description": "Velg alle tavler med størrelse du vil importere", - "action": { - "selectAll": "Velg alle", - "unselectAll": "Velg bort alle" - } - }, - "summary": { - "title": "Importoversikt", - "description": "I oversikten nedenfor kan du se hva som vil bli importert", - "action": { - "import": "Bekreft import og fortsett" - }, - "entities": { - "apps": "Apper", - "boards": "Tavler", - "integrations": "Integrasjoner", - "credentialUsers": "Påloggings-brukere" - } - }, - "tokenModal": { - "title": "Angi import-token", - "field": { - "token": { - "label": "Token", - "description": "Angi vist import-token for din forrige Homarr forekomst" - } - }, - "notification": { - "error": { - "title": "Ugyldig token", - "message": "Token du anga er ugyldig" - } - } - } - }, - "user": { - "title": "Admin-bruker", - "subtitle": "Angi innloggingsdetaljene for administratorbrukeren din.", - "notification": { - "success": { - "title": "Bruker opprettet", - "message": "Bruker opprettet" - }, - "error": { - "title": "Brukeropprettelse feilet" - } - } - }, - "group": { - "title": "Ekstern gruppe", - "subtitle": "Angi gruppen som skal brukes for eksterne brukere.", - "form": { - "name": { - "label": "Gruppenavn", - "description": "Navnet må stemme med administratorgruppe av ekstern leverandør" - } - } - }, - "settings": { - "title": "Innstillinger", - "subtitle": "Konfigurer serverinnstillinger." - }, - "integrations": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "continue": "", - "skip": "" - }, - "configure": { - "progress": "", - "skip": "", - "finishing": "", - "error": { - "title": "", - "message": "" - }, - "helpers": { - "tooltip": "", - "description": "" - } - }, - "urlTemplate": { - "label": "", - "placeholder": "", - "hint": "", - "warning": "", - "preview": "", - "mode": { - "subdomain": "", - "hostPort": "" - } - }, - "docker": { - "detected": "", - "detectedBreakdown": "", - "summary": "", - "appBadge": "", - "appSectionLabel": "", - "scanning": "", - "scanningDescription": "" - } - }, - "finish": { - "title": "Fullfør oppsett", - "subtitle": "Alt er klart!", - "description": "Du har fullført installasjonsprosessen. Du kan nå bruke Homarr. Velg din neste handling:", - "action": { - "goToBoard": "Gå til {name} tavlen", - "createBoard": "Opprett din første tavle", - "inviteUser": "Inviter andre brukere", - "docs": "Les dokumentasjonen" - } - } - }, - "backToStart": "Tilbake til start" - }, + "init": {}, "user": { "title": "Brukere", "name": "Bruker", @@ -3989,62 +3840,6 @@ "message": "Du har ulagrede endringer, er du sikker på at du vil avslutte?" } }, - "oldImport": { - "label": "Importer fra homarr før 1.0.0", - "notification": { - "success": { - "title": "Importen var vellykket", - "message": "Tavlen ble importert" - }, - "error": { - "title": "Import feilet", - "message": "Tavlen kunne ikke importeres, sjekk loggene for ytterligere detaljer" - } - }, - "form": { - "file": { - "label": "Velg JSON-fil", - "invalidError": "Ugyldig konfigurasjonsfil" - }, - "apps": { - "label": "Apper", - "avoidDuplicates": { - "label": "Unngå duplikater", - "description": "Ignorerer apper der en app med samme href allerede eksisterer" - }, - "onlyImportApps": { - "label": "Bare importer apper", - "description": "Bare legge til appene, tavlen må gjenskapes manuelt" - } - }, - "name": { - "label": "Navn på tavle" - }, - "screenSize": { - "label": "Skjerm størrelse", - "description": "I versjoner før 1.0 fantes tre forskjellige moduser, slik at du kunne velge antall kolonner for hver skjermstørrelse.", - "option": { - "sm": "Liten", - "md": "Medium", - "lg": "Stor" - } - }, - "sidebarBehavior": { - "label": "Oppførsel for sidepanelet", - "description": "Sidestolper ble fjernet i 1.0, du kan velge hva som skal skje med elementene inne i dem.", - "option": { - "lastSection": { - "label": "Siste seksjon", - "description": "Sidepanelet vil bli vist under den siste seksjonen" - }, - "removeItems": { - "label": "Slett elementer", - "description": "Elementer som finnes i sidepanelet vil bli fjernet" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Opprett app direkte", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "Opprett en ny tavle" }, - "importBoard": { - "label": "Importer en tavle" - }, "newApp": { "label": "Opprett en ny app" }, diff --git a/packages/translation/src/lang/pl.json b/packages/translation/src/lang/pl.json index baa0f41ac..efb522d4f 100644 --- a/packages/translation/src/lang/pl.json +++ b/packages/translation/src/lang/pl.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Witaj w Homarr", - "subtitle": "Zacznijmy od konfiguracji Twojej instancji Homarr.", - "description": "Aby rozpocząć, wybierz jak chcesz skonfigurować instancję Homarr.", - "action": { - "scratch": "Zacznij od zera", - "importOldmarr": "Importuj z Homarr przed 1.0", - "restoreSqlite": "Przywróć z kopii zapasowej SQLite", - "restoreSqliteDescription": "Prześlij pełną kopię zapasową bazy danych SQLite z poprzedniej instancji Homarr. Spowoduje to przywrócenie wszystkich tablic, użytkowników, aplikacji, integracji i ustawień.", - "backToOptions": "← Wróć do opcji konfiguracji" - } - }, - "import": { - "title": "Importuj dane", - "subtitle": "Możesz zaimportować dane z istniejącej instancji Homarra.", - "dropzone": { - "title": "Przeciągnij tutaj plik zip lub kliknij, aby przeglądać", - "description": "Przesłany zip zostanie przetworzony i będziesz mógł wybrać co chcesz zaimportować" - }, - "fileInfo": { - "action": { - "change": "Zmień plik" - } - }, - "importSettings": { - "title": "Importuj ustawienia", - "description": "Skonfiguruj zachowanie importu" - }, - "boardSelection": { - "title": "Znaleziono {count} tablic", - "description": "Wybierz wszystkie tablice o rozmiarze do zaimportowania", - "action": { - "selectAll": "Zaznacz wszystkie", - "unselectAll": "Odznacz wszystkie" - } - }, - "summary": { - "title": "Podsumowanie importu", - "description": "W poniższym podsumowaniu możesz zobaczyć, co zostanie zaimportowane", - "action": { - "import": "Potwierdź import i kontynuuj" - }, - "entities": { - "apps": "Aplikacje", - "boards": "Tablice", - "integrations": "Integracje", - "credentialUsers": "Dane użytkowników" - } - }, - "tokenModal": { - "title": "Wpisz token importu", - "field": { - "token": { - "label": "Token", - "description": "Wprowadź token importu z poprzedniej instancji homarr" - } - }, - "notification": { - "error": { - "title": "Nieprawidłowy token", - "message": "Wprowadzony token jest nieprawidłowy" - } - } - } - }, - "user": { - "title": "Administrator", - "subtitle": "Określ dane uwierzytelniające dla administratora", - "notification": { - "success": { - "title": "Użytkownik został utworzony", - "message": "Użytkownik został pomyślnie utworzony" - }, - "error": { - "title": "Błąd podczas tworzenia użytkownika" - } - } - }, - "group": { - "title": "Zewnętrzna grupa", - "subtitle": "Określ grupę, która powinna być używana przez użytkowników zewnętrznych.", - "form": { - "name": { - "label": "Nazwa grupy", - "description": "Nazwa musi być zgodna z grupą administratora zewnętrznego dostawcy" - } - } - }, - "settings": { - "title": "Ustawienia", - "subtitle": "Konfiguruj ustawienia serwera" - }, - "integrations": { - "title": "Integracje", - "subtitle": "Wybierz usługi, których używasz.", - "description": "Wybierz integracje, które chcesz połączyć. Skonfigurujemy Twoją tablicę z pasującymi widgetami automatycznie.", - "action": { - "continue": "Kontynuuj", - "skip": "Pomiń na razie" - }, - "configure": { - "progress": "{current} z {total}", - "skip": "Pomiń tę integrację", - "finishing": "Konfigurowanie twojej tablicy...", - "error": { - "title": "Konfiguracja nie powiodła się", - "message": "Coś poszło nie tak podczas konfiguracji twojej tablicy. Spróbuj ponownie później." - }, - "helpers": { - "tooltip": "URL helpers", - "description": "URL helpers spróbują odgadnąć URL twoich serwisów. Jeżeli to nie zadziała, po prostu wpisz adres ręcznie." - } - }, - "urlTemplate": { - "label": "Base URL", - "placeholder": "homelab.local lub 192.168.1.1", - "hint": "Wprowadź swoją podstawową domenę lub adres IP, aby automatycznie wypełniać adresy URL integracji", - "warning": "Ustawienie Base URL spowoduje automatyczne wypełnienie wszystkich adresów URL integracji, oszczędzając czas podczas konfiguracji", - "preview": "np. {example}", - "mode": { - "subdomain": "Subdomena", - "hostPort": "Host:Port" - } - }, - "docker": { - "detected": "Doker wykrył {count} usług", - "detectedBreakdown": "Integracje {integrations} · {apps} aplikacje", - "summary": "Homarr wykrył {apps} aplikacji, z których {integrations} ma wbudowane integracje", - "appBadge": "Aplikacja", - "appSectionLabel": "Aplikacje do dodania do tablicy (wybrano {count})", - "scanning": "Skanowanie Dockera...", - "scanningDescription": "Szukanie uruchomionych kontenerów w systemie" - } - }, - "finish": { - "title": "Zakończ konfigurację", - "subtitle": "Gotowe!", - "description": "Pomyślnie zakończyłeś proces konfiguracji. Teraz możesz zacząć używać Homarr. Wybierz następną akcję:", - "action": { - "goToBoard": "Idź do tablicy {name}", - "createBoard": "Stwórz swoją pierwszą tablicę", - "inviteUser": "Zaproś innych użytkowników", - "docs": "Przeczytaj dokumentację" - } - } - }, - "backToStart": "Powrót do początku" - }, + "init": {}, "user": { "title": "Użytkownicy", "name": "Użytkownik", @@ -4058,62 +3909,6 @@ "message": "Masz niezapisane zmiany, czy na pewno chcesz wyjść?" } }, - "oldImport": { - "label": "Import z Homarr sprzed wersji 1.0.0", - "notification": { - "success": { - "title": "Import zakończony pomyślnie", - "message": "Tablica została pomyślnie zaimportowana" - }, - "error": { - "title": "Import nie powiódł się", - "message": "Nie udało się zaimportować tablicy. Sprawdź logi, aby uzyskać więcej informacji." - } - }, - "form": { - "file": { - "label": "Wybierz plik JSON", - "invalidError": "Nieprawidłowy plik konfiguracji" - }, - "apps": { - "label": "Aplikacje", - "avoidDuplicates": { - "label": "Unikaj duplikatów", - "description": "Ignoruje aplikacje, dla których już istnieje aplikacja z tym samym adresem href." - }, - "onlyImportApps": { - "label": "Import tylko aplikacje", - "description": "Dodaje tylko aplikacje, tablica musi zostać odtworzona ręcznie." - } - }, - "name": { - "label": "Nazwa tablicy" - }, - "screenSize": { - "label": "Rozmiar ekranu", - "description": "W wersjach przed 1.0 istniały trzy różne tryby, dzięki czemu można było wybrać liczbę kolumn dla każdego rozmiaru ekranu.", - "option": { - "sm": "Mały", - "md": "Średni", - "lg": "Duży" - } - }, - "sidebarBehavior": { - "label": "Zachowanie paska bocznego", - "description": "Paski boczne zostały usunięte w wersji 1.0, możesz wybrać, co ma się stać z elementami, które się w nich znajdowały.", - "option": { - "lastSection": { - "label": "Ostatnia sekcja", - "description": "Pasek boczny będzie wyświetlany poniżej ostatniej sekcji" - }, - "removeItems": { - "label": "Usuń elementy", - "description": "Elementy znajdujące się w pasku bocznym zostaną usunięte" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Utwórz aplikację w locie", @@ -5737,9 +5532,6 @@ "newBoard": { "label": "Utwórz nową tablicę" }, - "importBoard": { - "label": "Importuj tablicę" - }, "newApp": { "label": "Utwórz nową aplikację" }, diff --git a/packages/translation/src/lang/pt-br.json b/packages/translation/src/lang/pt-br.json index a78191198..089ad7564 100644 --- a/packages/translation/src/lang/pt-br.json +++ b/packages/translation/src/lang/pt-br.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Bem-vindo ao Homarr", - "subtitle": "Vamos começar configurando a sua instância Homarr.", - "description": "Para começar, por favor selecione como você deseja configurar sua instância Homarr.", - "action": { - "scratch": "Começar do zero", - "importOldmarr": "Importe de uma instância do Homarr anterior a 1.0", - "restoreSqlite": "Restaurar do backup SQLite", - "restoreSqliteDescription": "Fazer o upload de um backup completo do banco de dados SQLite de uma instância anterior da Homarr. Isso restaurará todos os painéis, usuários, apps, integrações e configurações.", - "backToOptions": "← Retorna às opções de instalação" - } - }, - "import": { - "title": "Importar dados", - "subtitle": "Você pode importar dados de uma instância existente do Homarr.", - "dropzone": { - "title": "Arraste o arquivo ZIP aqui ou clique para procurar", - "description": "O arquivo ZIP enviado será processado e você poderá selecionar o que deseja importar" - }, - "fileInfo": { - "action": { - "change": "Alterar arquivo" - } - }, - "importSettings": { - "title": "Importar configurações", - "description": "Configure o comportamento da importação" - }, - "boardSelection": { - "title": "{count} painéis encontrados", - "description": "Escolha todos os painéis com o tamanho que você deseja importar", - "action": { - "selectAll": "Selecionar todos", - "unselectAll": "Desmarcar Todos" - } - }, - "summary": { - "title": "Resumo da importação", - "description": "No resumo abaixo você pode ver o que será importado", - "action": { - "import": "Confirmar a importação e continuar" - }, - "entities": { - "apps": "Aplicativos", - "boards": "Painéis", - "integrations": "Integrações", - "credentialUsers": "Usuários credenciados" - } - }, - "tokenModal": { - "title": "Insira o token de importação", - "field": { - "token": { - "label": "Token", - "description": "Insira o token de importação mostrado em sua instância homarr anterior" - } - }, - "notification": { - "error": { - "title": "Token inválido", - "message": "O token que você inseriu é inválido" - } - } - } - }, - "user": { - "title": "Usuário Administrador", - "subtitle": "Especifique as credenciais para seu usuário administrador.", - "notification": { - "success": { - "title": "Usuário criado", - "message": "O usuário foi criado com sucesso" - }, - "error": { - "title": "Falha ao criar usuário" - } - } - }, - "group": { - "title": "Grupo externo", - "subtitle": "Especifique o grupo que deve ser usado para usuários externos.", - "form": { - "name": { - "label": "Nome do grupo", - "description": "O nome tem que coincidir com o grupo administrador do provedor externo" - } - } - }, - "settings": { - "title": "Configurações", - "subtitle": "Definir as configurações do servidor." - }, - "integrations": { - "title": "Integrações", - "subtitle": "Selecione os serviços que você usa.", - "description": "Escolha as integrações que deseja conectar. Vamos configurar seu painel com os widgets correspondentes automaticamente.", - "action": { - "continue": "Continuar", - "skip": "Pular por enquanto" - }, - "configure": { - "progress": "{current} de {total}", - "skip": "Pular esta integração", - "finishing": "Configurando seu painel...", - "error": { - "title": "Falha na configuração", - "message": "Algo deu errado ao configurar seu painel. Por favor, tente novamente." - }, - "helpers": { - "tooltip": "Auxiliares de URL", - "description": "Esses auxiliares tentam adivinhar a URL dos seus serviços. Se não funcionar, digite o endereço manualmente." - } - }, - "urlTemplate": { - "label": "URL base", - "placeholder": "homelab.local ou 192.168.1.1", - "hint": "Digite seu domínio base ou IP para preencher automaticamente as URLs das integrações", - "warning": "Definir sua URL base agora preencherá automaticamente todas as URLs das integrações, economizando tempo durante a configuração", - "preview": "ex.: {example}", - "mode": { - "subdomain": "Subdomínio", - "hostPort": "Host:Porta" - } - }, - "docker": { - "detected": "Docker detectou {count} serviços", - "detectedBreakdown": "{integrations} integrações · {apps} apps", - "summary": "O Homarr detectou {apps} apps, dos quais {integrations} possuem integrações nativas", - "appBadge": "App", - "appSectionLabel": "Apps para adicionar ao painel ({count} selecionados)", - "scanning": "Verificando Docker...", - "scanningDescription": "Procurando contêineres em execução no seu sistema" - } - }, - "finish": { - "title": "Concluir configuração", - "subtitle": "Você está pronto para começar!", - "description": "Você concluiu com sucesso o processo de configuração. Agora você pode começar a usar o Homarr. Selecione sua próxima ação:", - "action": { - "goToBoard": "Ir para o painel {name}", - "createBoard": "Crie seu primeiro painel", - "inviteUser": "Convidar outros usuários", - "docs": "Ler a documentação" - } - } - }, - "backToStart": "Voltar ao início" - }, + "init": {}, "user": { "title": "Usuários", "name": "Usuário", @@ -4041,62 +3892,6 @@ "message": "Você tem alterações não salvas, tem certeza de que deseja sair?" } }, - "oldImport": { - "label": "Importar do Homarr anterior à versão 1.0.0", - "notification": { - "success": { - "title": "Importação bem-sucedida", - "message": "O painel foi importado com sucesso" - }, - "error": { - "title": "Falha na importação", - "message": "O painel não pôde ser importado, verifique os logs para mais detalhes" - } - }, - "form": { - "file": { - "label": "Selecionar arquivo JSON", - "invalidError": "Arquivo de configuração inválido" - }, - "apps": { - "label": "Aplicativos", - "avoidDuplicates": { - "label": "Evitar duplicatas", - "description": "Ignora aplicativos onde já existe um aplicativo com o mesmo href" - }, - "onlyImportApps": { - "label": "Importar apenas aplicativos", - "description": "Só adiciona os aplicativos, o painel precisa ser recriado manualmente" - } - }, - "name": { - "label": "Nome do painel" - }, - "screenSize": { - "label": "Tamanho da tela", - "description": "Nas versões anteriores à 1.0 existiam três modos diferentes, então você podia escolher a quantidade de colunas para cada tamanho de tela.", - "option": { - "sm": "Pequeno", - "md": "Médio", - "lg": "Grande" - } - }, - "sidebarBehavior": { - "label": "Comportamento da barra lateral", - "description": "As barras laterais foram removidas na versão 1.0. Você pode selecionar o que deve acontecer com os itens dentro delas.", - "option": { - "lastSection": { - "label": "Última seção", - "description": "A barra lateral será exibida abaixo da última seção" - }, - "removeItems": { - "label": "Remover itens", - "description": "Os itens presentes na barra lateral serão removidos" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Criar aplicativo em tempo real", @@ -5707,9 +5502,6 @@ "newBoard": { "label": "Criar um novo quadro" }, - "importBoard": { - "label": "Importe um quadro" - }, "newApp": { "label": "Criar aplicativo" }, diff --git a/packages/translation/src/lang/pt.json b/packages/translation/src/lang/pt.json index 3367bad51..345a76294 100644 --- a/packages/translation/src/lang/pt.json +++ b/packages/translation/src/lang/pt.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "scratch": "", - "importOldmarr": "", - "restoreSqlite": "Restaurar do backup SQLite", - "restoreSqliteDescription": "Carregar um backup completo do banco de dados SQLite de uma instância anterior do Homarr. Isso restaurará todos os painéis, usuários, aplicativos, integrações e configurações.", - "backToOptions": "← Voltar para opções de configuração" - } - }, - "import": { - "title": "", - "subtitle": "", - "dropzone": { - "title": "", - "description": "" - }, - "fileInfo": { - "action": { - "change": "" - } - }, - "importSettings": { - "title": "", - "description": "" - }, - "boardSelection": { - "title": "", - "description": "", - "action": { - "selectAll": "", - "unselectAll": "" - } - }, - "summary": { - "title": "", - "description": "", - "action": { - "import": "" - }, - "entities": { - "apps": "", - "boards": "", - "integrations": "", - "credentialUsers": "" - } - }, - "tokenModal": { - "title": "", - "field": { - "token": { - "label": "", - "description": "" - } - }, - "notification": { - "error": { - "title": "", - "message": "" - } - } - } - }, - "user": { - "title": "", - "subtitle": "", - "notification": { - "success": { - "title": "", - "message": "" - }, - "error": { - "title": "" - } - } - }, - "group": { - "title": "", - "subtitle": "", - "form": { - "name": { - "label": "", - "description": "" - } - } - }, - "settings": { - "title": "", - "subtitle": "" - }, - "integrations": { - "title": "Integrações", - "subtitle": "Selecione os serviços que você usa.", - "description": "Escolha as integrações que você gostaria de conectar. Vamos configurar seu painel com widgets correspondentes automaticamente.", - "action": { - "continue": "Continuar", - "skip": "Pular por enquanto" - }, - "configure": { - "progress": "{current} de {total}", - "skip": "Pular esta integração", - "finishing": "Configurando seu painel...", - "error": { - "title": "Falha na configuração", - "message": "Algo deu errado ao configurar seu painel. Por favor, tente novamente." - }, - "helpers": { - "tooltip": "Ajudantes de URL", - "description": "Esses ajudantes tentam adivinhar a URL para seus serviços. Se não funcionar, basta digitar o endereço manualmente." - } - }, - "urlTemplate": { - "label": "URL base", - "placeholder": "homelab.local ou 192.168.1.1", - "hint": "Digite seu domínio base ou IP para preencher automaticamente as URLs de integração", - "warning": "Definir sua URL base agora preencherá automaticamente todas as URLs de integração, economizando tempo durante a configuração", - "preview": "ex.: {example}", - "mode": { - "subdomain": "Subdomínio", - "hostPort": "Host:Porta" - } - }, - "docker": { - "detected": "Docker detectou {count} serviços", - "detectedBreakdown": "{integrations} integrações · {apps} apps", - "summary": "Homarr detectou {apps} aplicativos, {integrations} dos quais têm integrações integradas", - "appBadge": "Aplicativo", - "appSectionLabel": "Apps para adicionar ao painel ({count} selecionados)", - "scanning": "Escaneando Docker...", - "scanningDescription": "Procurando contêineres em execução no seu sistema" - } - }, - "finish": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "goToBoard": "", - "createBoard": "", - "inviteUser": "", - "docs": "" - } - } - }, - "backToStart": "" - }, + "init": {}, "user": { "title": "Usuários", "name": "Usuário", @@ -3989,62 +3840,6 @@ "message": "Você tem alterações não salvas, tem certeza de que deseja sair?" } }, - "oldImport": { - "label": "Importar do Homarr anterior à versão 1.0.0", - "notification": { - "success": { - "title": "Importação bem-sucedida", - "message": "O painel foi importado com sucesso" - }, - "error": { - "title": "Falha na importação", - "message": "O painel não pôde ser importado, verifique os logs para mais detalhes" - } - }, - "form": { - "file": { - "label": "Selecionar arquivo JSON", - "invalidError": "Arquivo de configuração inválido" - }, - "apps": { - "label": "Aplicativos", - "avoidDuplicates": { - "label": "Evitar duplicatas", - "description": "Ignora aplicativos onde já existe um aplicativo com o mesmo href" - }, - "onlyImportApps": { - "label": "Importar apenas aplicativos", - "description": "Apenas adiciona os aplicativos, o painel precisa ser recriado manualmente" - } - }, - "name": { - "label": "Nome do painel" - }, - "screenSize": { - "label": "Tamanho da tela", - "description": "", - "option": { - "sm": "Pequeno", - "md": "Médio", - "lg": "Grande" - } - }, - "sidebarBehavior": { - "label": "Comportamento da barra lateral", - "description": "As barras laterais foram removidas na versão 1.0. Você pode selecionar o que deve acontecer com os itens dentro delas.", - "option": { - "lastSection": { - "label": "Última seção", - "description": "A barra lateral será exibida abaixo da última seção" - }, - "removeItems": { - "label": "Remover itens", - "description": "Os itens contidos na barra lateral serão removidos" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "" }, - "importBoard": { - "label": "" - }, "newApp": { "label": "" }, diff --git a/packages/translation/src/lang/ro.json b/packages/translation/src/lang/ro.json index e25372787..de5b20613 100644 --- a/packages/translation/src/lang/ro.json +++ b/packages/translation/src/lang/ro.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Bun venit", - "subtitle": "Să începem cu configurarea Homarr.", - "description": "Pentru a începe, selectează modul în care vrei să setezi Homarr.", - "action": { - "scratch": "Începe de la zero", - "importOldmarr": "Importă din Homarr înainte de 1.0", - "restoreSqlite": "Restaurează dintr-un backup SQLite", - "restoreSqliteDescription": "Încarcă o copie completă a bazei de date SQLite dintr-o instalare anterioară a Homarr. Aceasta va restaura toate interfețele, utilizatorii, aplicaţiile, integrările şi setările.", - "backToOptions": "← Înapoi la opțiunile de configurare" - } - }, - "import": { - "title": "Importare date", - "subtitle": "Puteți importa date dintr-o instalare Homarr existentă.", - "dropzone": { - "title": "Trage fişierul zip aici sau apasă pentru a-l căuta", - "description": "Fișierul zip încărcat va fi procesat și vei putea selecta ceea ce dorești să imporți" - }, - "fileInfo": { - "action": { - "change": "Schimbă fișierul" - } - }, - "importSettings": { - "title": "Importă Setări", - "description": "Configurare comportamentul de importare" - }, - "boardSelection": { - "title": "S-au găsit {count} secţiuni", - "description": "Alege toate secțiunile cu dimensiunea pe care dorești să o imporți", - "action": { - "selectAll": "Selectează tot", - "unselectAll": "Deselectează tot" - } - }, - "summary": { - "title": "Rezumat importare", - "description": "În rezumatul de mai jos poți vedea ce va fi importat", - "action": { - "import": "Confirmă importul şi continuă" - }, - "entities": { - "apps": "Aplicații", - "boards": "Planșe", - "integrations": "Integrări", - "credentialUsers": "Utilizatori acreditați" - } - }, - "tokenModal": { - "title": "Introdu token-ul de import", - "field": { - "token": { - "label": "Token-ul", - "description": "Introdu token-ul de import afișat în instalarea de homarr anterioară" - } - }, - "notification": { - "error": { - "title": "Token invalid", - "message": "Token-ul introdus e invalid" - } - } - } - }, - "user": { - "title": "Utilizator Administrator", - "subtitle": "Specifică credențialele pentru utilizatorul administrator.", - "notification": { - "success": { - "title": "Utilizator creat", - "message": "Utilizatorul a fost creat cu succes" - }, - "error": { - "title": "Crearea utilizatorului a eșuat" - } - } - }, - "group": { - "title": "Grup extern", - "subtitle": "Specifică grupul care ar trebui să fie utilizat pentru utilizatori externi.", - "form": { - "name": { - "label": "Nume grup", - "description": "Numele trebuie să corespundă grupului de administratori al furnizorului extern" - } - } - }, - "settings": { - "title": "Setări", - "subtitle": "Configurare setări server." - }, - "integrations": { - "title": "Integrări", - "subtitle": "Selectează serviciile pe care le folosești.", - "description": "Alegeți integrările pe care doriți să le conectați. Vă vom configura tabloul de bord cu widget-uri care se potrivesc automat.", - "action": { - "continue": "Continuă", - "skip": "Omite pentru moment" - }, - "configure": { - "progress": "{current} din {total}", - "skip": "Omite această integrare", - "finishing": "Se configurează tabloul de bord...", - "error": { - "title": "Configurarea a eșuat", - "message": "Ceva nu a mers bine în timpul configurării tabloului de bord. Te rugăm să încerci din nou." - }, - "helpers": { - "tooltip": "Helperi URL", - "description": "Acești helperi încearcă să ghicească URL-ul serviciilor tale. Dacă nu funcționează, introdu adresa manual." - } - }, - "urlTemplate": { - "label": "URL de bază", - "placeholder": "homelab.local sau 192.168.1.1", - "hint": "Introdu domeniul de bază sau IP-ul pentru a completa automat adresele URL ale integrărilor", - "warning": "Setarea adresei URL de bază acum va completa automat toate URL-urile integrărilor, scurtând timpul necesar configurării", - "preview": "de ex. {example}", - "mode": { - "subdomain": "Subdomeniu", - "hostPort": "Gazdă:Port" - } - }, - "docker": { - "detected": "Docker a detectat {count} servicii", - "detectedBreakdown": "{integrations} integrări · {apps} aplicații", - "summary": "Homarr a detectat {apps} aplicații, dintre care {integrations} au integrări încorporate", - "appBadge": "Aplicație", - "appSectionLabel": "Aplicații de adăugat la tabloul de bord ({count} selectate)", - "scanning": "Scanare Docker...", - "scanningDescription": "Căutare containere care rulează pe sistem" - } - }, - "finish": { - "title": "Finalizează configurarea", - "subtitle": "Ești gata să începi!", - "description": "Ai finalizat cu succes procesul de configurare. Acum poți începe să utilizezi Homarr. Selectează următoarea acțiune:", - "action": { - "goToBoard": "Mergi la panoul {name}", - "createBoard": "Creează primul tău panou", - "inviteUser": "Invită alți utilizatori", - "docs": "Citește documentația" - } - } - }, - "backToStart": "Înapoi la început" - }, + "init": {}, "user": { "title": "Utilizatori", "name": "Utilizator", @@ -3989,62 +3840,6 @@ "message": "" } }, - "oldImport": { - "label": "", - "notification": { - "success": { - "title": "", - "message": "" - }, - "error": { - "title": "", - "message": "" - } - }, - "form": { - "file": { - "label": "", - "invalidError": "" - }, - "apps": { - "label": "Aplicații", - "avoidDuplicates": { - "label": "", - "description": "" - }, - "onlyImportApps": { - "label": "", - "description": "" - } - }, - "name": { - "label": "" - }, - "screenSize": { - "label": "", - "description": "", - "option": { - "sm": "Mic", - "md": "Mediu", - "lg": "Mare" - } - }, - "sidebarBehavior": { - "label": "", - "description": "", - "option": { - "lastSection": { - "label": "", - "description": "" - }, - "removeItems": { - "label": "", - "description": "" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "" }, - "importBoard": { - "label": "" - }, "newApp": { "label": "" }, diff --git a/packages/translation/src/lang/ru.json b/packages/translation/src/lang/ru.json index f739bbbf8..36bd80259 100644 --- a/packages/translation/src/lang/ru.json +++ b/packages/translation/src/lang/ru.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Добро пожаловать в Homarr", - "subtitle": "Давайте начнем настройку вашего экземпляра Homarr.", - "description": "Для начала выберите, как вы хотите настроить ваш экземпляр Homarr.", - "action": { - "scratch": "Начать с нуля", - "importOldmarr": "Импортировать из Homarr версии до 1.0", - "restoreSqlite": "Восстановить из резервной копии SQLite", - "restoreSqliteDescription": "Загрузите полную резервную копию базы данных SQLite из предыдущего экземпляра Homarr. Это восстановит все панели, пользователей, приложения, интеграции и настройки.", - "backToOptions": "← Назад к вариантам настройки" - } - }, - "import": { - "title": "Импорт данных", - "subtitle": "Вы можете импортировать данные из существующего экземпляра Homarr.", - "dropzone": { - "title": "Перетащите ZIP-файл сюда или нажмите для его выбора", - "description": "Загруженный ZIP-файл будет обработан, и вы сможете выбрать, что хотите импортировать" - }, - "fileInfo": { - "action": { - "change": "Изменить файл" - } - }, - "importSettings": { - "title": "Настройки импорта", - "description": "Настройте параметры импорта" - }, - "boardSelection": { - "title": "Найдено {count} панелей", - "description": "Выберите все панели с их размерами, которые вы хотите импортировать", - "action": { - "selectAll": "Выбрать все", - "unselectAll": "Снять выбор со всех" - } - }, - "summary": { - "title": "Сводка по импорту", - "description": "В приведенной ниже сводке вы можете увидеть, что будет импортировано", - "action": { - "import": "Подтвердить импорт и продолжить" - }, - "entities": { - "apps": "Приложения", - "boards": "Панели", - "integrations": "Интеграции", - "credentialUsers": "Учетные данные пользователя" - } - }, - "tokenModal": { - "title": "Введите токен импорта", - "field": { - "token": { - "label": "Токен", - "description": "Введите показанный токен импорта из вашего предыдущего экземпляра Homarr" - } - }, - "notification": { - "error": { - "title": "Недействительный токен", - "message": "Введенный токен недействителен" - } - } - } - }, - "user": { - "title": "Администратор системы", - "subtitle": "Укажите учетные данные для администратора системы.", - "notification": { - "success": { - "title": "Пользователь создан", - "message": "Пользователь успешно создан" - }, - "error": { - "title": "Не удалось создать пользователя" - } - } - }, - "group": { - "title": "Внешняя группа", - "subtitle": "Укажите группу, которая будет использоваться для внешних пользователей.", - "form": { - "name": { - "label": "Название группы", - "description": "Название должно соответствовать группе администраторов внешнего провайдера" - } - } - }, - "settings": { - "title": "Настройки", - "subtitle": "Настройте параметры сервера." - }, - "integrations": { - "title": "Интеграции", - "subtitle": "Выберите используемые сервисы.", - "description": "Выберите интеграции, которые хотите подключить. Мы автоматически настроим панель с соответствующими виджетами.", - "action": { - "continue": "Продолжить", - "skip": "Пропустить" - }, - "configure": { - "progress": "{current} из {total}", - "skip": "Пропустить эту интеграцию", - "finishing": "Настройка панели...", - "error": { - "title": "Ошибка настройки", - "message": "Что-то пошло не так при настройке панели. Пожалуйста, попробуйте снова." - }, - "helpers": { - "tooltip": "Помощники URL", - "description": "Эти помощники пытаются угадать URL ваших сервисов. Если не сработает, просто введите адрес вручную." - } - }, - "urlTemplate": { - "label": "Базовый URL", - "placeholder": "homelab.local или 192.168.1.1", - "hint": "Введите базовый домен или IP для автозаполнения URL интеграций", - "warning": "Установка базового URL сейчас автоматически заполнит все URL интеграций, сэкономив время при настройке", - "preview": "например, {example}", - "mode": { - "subdomain": "Поддомен", - "hostPort": "Хост:Порт" - } - }, - "docker": { - "detected": "Docker обнаружил {count} сервисов", - "detectedBreakdown": "{integrations} интеграций · {apps} приложений", - "summary": "Homarr обнаружил {apps} приложений, {integrations} из которых имеют встроенные интеграции", - "appBadge": "Приложение", - "appSectionLabel": "Приложения для добавления на панель (выбрано {count})", - "scanning": "Сканирование Docker...", - "scanningDescription": "Поиск запущенных контейнеров в системе" - } - }, - "finish": { - "title": "Завершение настройки", - "subtitle": "Всё готово к работе!", - "description": "Вы успешно завершили процесс настройки. Теперь вы можете начать использовать Homarr. Выберите следующее действие:", - "action": { - "goToBoard": "Перейти к панели {name}", - "createBoard": "Создать первую панель", - "inviteUser": "Пригласить других пользователей", - "docs": "Прочитать документацию" - } - } - }, - "backToStart": "Вернуться к началу" - }, + "init": {}, "user": { "title": "Пользователи", "name": "Пользователь", @@ -3989,62 +3840,6 @@ "message": "У вас есть несохранённые изменения. Вы уверены, что хотите выйти?" } }, - "oldImport": { - "label": "Импорт из версии Homarr до 1.0.0", - "notification": { - "success": { - "title": "Импорт успешно выполнен", - "message": "Панель успешно импортирована" - }, - "error": { - "title": "Импорт не удался", - "message": "Не удалось импортировать панель, проверьте логи для дополнительной информации" - } - }, - "form": { - "file": { - "label": "Выберите JSON-файл", - "invalidError": "Недействительный файл конфигурации" - }, - "apps": { - "label": "Приложения", - "avoidDuplicates": { - "label": "Избегать дубликатов", - "description": "Игнорирует приложения, для которых уже существует приложение с тем же URL-адресом" - }, - "onlyImportApps": { - "label": "Импортировать только приложения", - "description": "Добавляет только приложения, панель нужно будет создать заново вручную" - } - }, - "name": { - "label": "Название панели" - }, - "screenSize": { - "label": "Размер экрана", - "description": "В версиях до 1.0 существовало три разных режима, поэтому вы могли выбрать количество столбцов для каждого размера экрана.", - "option": { - "sm": "Маленький", - "md": "Средний", - "lg": "Большой" - } - }, - "sidebarBehavior": { - "label": "Поведение боковых панелей", - "description": "Боковые панели были удалены в версии 1.0, вы можете выбрать, что должно произойти с элементами внутри них.", - "option": { - "lastSection": { - "label": "Последний раздел", - "description": "Боковая панель будет отображаться под последним разделом" - }, - "removeItems": { - "label": "Удалить элементы", - "description": "Элементы, содержащиеся в боковой панели, будут удалены" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Быстрое создание приложения", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "Создать новую панель" }, - "importBoard": { - "label": "Импортировать панель" - }, "newApp": { "label": "Создать новое приложение" }, diff --git a/packages/translation/src/lang/sk.json b/packages/translation/src/lang/sk.json index a84511aa7..9ce6adffe 100644 --- a/packages/translation/src/lang/sk.json +++ b/packages/translation/src/lang/sk.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Víta Vás Homár", - "subtitle": "Začnime s nastavením inštancie Homár.", - "description": "Ak chcete začať, vyberte, ako chcete nastaviť inštanciu Homár.", - "action": { - "scratch": "Začať odznova", - "importOldmarr": "Import z Homarr pred 1.0", - "restoreSqlite": "Obnoviť zo zálohy SQLite", - "restoreSqliteDescription": "Nahrajte úplnú zálohu databázy SQLite z predchádzajúcej inštancie Homarr. Tým sa obnovia všetky dosky, používatelia, aplikácie, integrácie a nastavenia.", - "backToOptions": "← Späť na možnosti nastavenia" - } - }, - "import": { - "title": "Importovať dáta", - "subtitle": "Údaje môžete importovať z existujúcej inštancie aplikácie Homarr.", - "dropzone": { - "title": "Potiahnite súbor ZIP sem alebo kliknite na tlačidlo prehľadávania", - "description": "Nahraný súbor zip sa spracuje a vy si budete môcť vybrať, čo chcete importovať." - }, - "fileInfo": { - "action": { - "change": "Zmeniť súbor" - } - }, - "importSettings": { - "title": "Import nastavení", - "description": "Nakonfigurujte správanie importu" - }, - "boardSelection": { - "title": "Nájdených {count} dosiek", - "description": "Vyberte všetky dosky s veľkosťou, ktorú chcete importovať", - "action": { - "selectAll": "Vybrať všetko", - "unselectAll": "Zrušiť výber" - } - }, - "summary": { - "title": "Súhrn importu", - "description": "V nasledujúcom prehľade môžete vidieť, čo sa bude importovať", - "action": { - "import": "Potvrďte import a pokračujte" - }, - "entities": { - "apps": "Aplikácie", - "boards": "Dosky", - "integrations": "Integrácie", - "credentialUsers": "Používatelia poverení" - } - }, - "tokenModal": { - "title": "Zadajte token importu", - "field": { - "token": { - "label": "Token", - "description": "Zadajte zobrazený token importu z predchádzajúcej inštancie homarr" - } - }, - "notification": { - "error": { - "title": "Neplatný token", - "message": "Zadaný token je neplatný" - } - } - } - }, - "user": { - "title": "Používateľ administrátora", - "subtitle": "Zadajte poverenia pre používateľa správcu.", - "notification": { - "success": { - "title": "Používateľ vytvorený", - "message": "Používateľ bol úspešne vytvorený" - }, - "error": { - "title": "Vytvorenie užívateľa zlyhalo" - } - } - }, - "group": { - "title": "Externá skupina", - "subtitle": "Zadajte skupinu, ktorá sa má použiť pre externých používateľov.", - "form": { - "name": { - "label": "Názov skupiny", - "description": "Názov sa musí zhodovať s administrátorskou skupinou externého poskytovateľa" - } - } - }, - "settings": { - "title": "Nastavenia", - "subtitle": "Nakonfigurujte nastavenia servera." - }, - "integrations": { - "title": "Integrácie", - "subtitle": "Vyberte služby, ktoré používate.", - "description": "Vyberte integrácie, ktoré chcete pripojiť. Automaticky nastavíme prístrojovú dosku so zodpovedajúcimi miniaplikáciami.", - "action": { - "continue": "Pokračovať", - "skip": "Teraz preskočiť" - }, - "configure": { - "progress": "{current} z {total}", - "skip": "Preskočiť túto integráciu", - "finishing": "Nastavuje sa prístrojová doska...", - "error": { - "title": "Nastavenie zlyhalo", - "message": "Pri nastavovaní prístrojovej dosky sa niečo pokazilo. Skúste to znova." - }, - "helpers": { - "tooltip": "Pomocníci URL", - "description": "Títo pomocníci sa snažia uhádnuť adresu URL pre vaše služby. Ak to nefunguje, jednoducho napíšte adresu ručne." - } - }, - "urlTemplate": { - "label": "Základná adresa URL", - "placeholder": "homelab.local alebo 192.168.1.1", - "hint": "Zadajte svoju základnú doménu alebo IP, aby sa automaticky vyplnili adresy URL integrácií", - "warning": "Nastavením základnej adresy URL teraz sa automaticky vyplnia všetky adresy URL integrácií, čím ušetríte čas počas nastavovania", - "preview": "napr. {example}", - "mode": { - "subdomain": "Subdoména", - "hostPort": "Hostiteľ:Port" - } - }, - "docker": { - "detected": "Docker rozpoznal {count} služieb", - "detectedBreakdown": "{integrations} integrácií · {apps} aplikácií", - "summary": "Homarr rozpoznal {apps} aplikácií, z ktorých {integrations} má vstavané integrácie", - "appBadge": "Aplikácia", - "appSectionLabel": "Aplikácie, ktoré sa pridajú na prístrojovú dosku ({count} vybratých)", - "scanning": "Skenuje sa Docker...", - "scanningDescription": "Hľadajú sa spustené kontajnery vo vašom systéme" - } - }, - "finish": { - "title": "Dokončiť nastavenie", - "subtitle": "Ste pripravení vyraziť!", - "description": "Úspešne ste dokončili proces nastavenia. Teraz môžete začať používať Homarr. Vyberte ďalšiu akciu:", - "action": { - "goToBoard": "Prejdite na nástenku {name}", - "createBoard": "Vytvorte si svoju prvú nástenku", - "inviteUser": "Pozvite ďalších používateľov", - "docs": "Prečítajte si dokumentáciu" - } - } - }, - "backToStart": "Späť na začiatok" - }, + "init": {}, "user": { "title": "Používatelia", "name": "Používateľ", @@ -3989,62 +3840,6 @@ "message": "Máte neuložené zmeny. Naozaj chcete odísť?" } }, - "oldImport": { - "label": "Import z homarru pred 1.0.0", - "notification": { - "success": { - "title": "Import úspešný", - "message": "Doska bola úspešne importovaná" - }, - "error": { - "title": "Import zlyhal.", - "message": "Dosku sa nepodarilo importovať, ďalšie podrobnosti nájdete v záznamoch" - } - }, - "form": { - "file": { - "label": "Vyberte súbor JSON", - "invalidError": "Neplatný konfiguračný súbor" - }, - "apps": { - "label": "Aplikácie", - "avoidDuplicates": { - "label": "Vyhnite sa duplikátom", - "description": "Ignoruje aplikácie, v ktorých už existuje aplikácia s rovnakým href" - }, - "onlyImportApps": { - "label": "Importujte iba aplikácie", - "description": "Pridá len aplikácie, dosku je potrebné vytvoriť znova ručne" - } - }, - "name": { - "label": "Názov dosky" - }, - "screenSize": { - "label": "Veľkosť obrazovky", - "description": "Vo verziách pred 1.0 existovali tri rôzne režimy, takže ste si mohli vybrať množstvo stĺpcov pre každú veľkosť obrazovky.", - "option": { - "sm": "Malé", - "md": "Stredné", - "lg": "Veľké" - } - }, - "sidebarBehavior": { - "label": "Správanie bočného panela", - "description": "Bočné panely boli odstránené vo verzii 1.0, môžete si vybrať, čo sa má stať s položkami v nich.", - "option": { - "lastSection": { - "label": "Posledná sekcia", - "description": "Pod poslednou sekciou sa zobrazí bočný panel" - }, - "removeItems": { - "label": "Odstrániť položky", - "description": "Položky na bočnom paneli budú odstránené" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Vytvorte aplikáciu za chodu", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "Vytvorte novú dosku" }, - "importBoard": { - "label": "Importujte dosku" - }, "newApp": { "label": "Vytvorte novú aplikáciu" }, diff --git a/packages/translation/src/lang/sl.json b/packages/translation/src/lang/sl.json index a5aa1d754..4ccc6daa8 100644 --- a/packages/translation/src/lang/sl.json +++ b/packages/translation/src/lang/sl.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "scratch": "", - "importOldmarr": "", - "restoreSqlite": "Obnovi iz varnostne kopije SQLite", - "restoreSqliteDescription": "Naložite celotno varnostno kopijo baze podatkov SQLite iz prejšnjega primerka Homarr. To bo obnovilo vse table, uporabnike, aplikacije, integracije in nastavitve.", - "backToOptions": "← Nazaj na možnosti nastavitve" - } - }, - "import": { - "title": "", - "subtitle": "", - "dropzone": { - "title": "", - "description": "" - }, - "fileInfo": { - "action": { - "change": "" - } - }, - "importSettings": { - "title": "", - "description": "" - }, - "boardSelection": { - "title": "", - "description": "", - "action": { - "selectAll": "", - "unselectAll": "" - } - }, - "summary": { - "title": "", - "description": "", - "action": { - "import": "" - }, - "entities": { - "apps": "Aplikacije", - "boards": "Deske", - "integrations": "", - "credentialUsers": "" - } - }, - "tokenModal": { - "title": "", - "field": { - "token": { - "label": "Žeton", - "description": "" - } - }, - "notification": { - "error": { - "title": "", - "message": "" - } - } - } - }, - "user": { - "title": "", - "subtitle": "", - "notification": { - "success": { - "title": "", - "message": "" - }, - "error": { - "title": "" - } - } - }, - "group": { - "title": "", - "subtitle": "", - "form": { - "name": { - "label": "", - "description": "" - } - } - }, - "settings": { - "title": "Nastavitve", - "subtitle": "" - }, - "integrations": { - "title": "Integracije", - "subtitle": "Izberite storitve, ki jih uporabljate.", - "description": "Izberite integracije, ki jih želite povezati. Samodejno bomo nastavili vašo nadzorno ploščo z ustreznimi gradniki.", - "action": { - "continue": "Nadaljuj", - "skip": "Zaenkrat preskoči" - }, - "configure": { - "progress": "{current} od {total}", - "skip": "Preskoči to integracijo", - "finishing": "Nastavljanje vaše nadzorne plošče ...", - "error": { - "title": "Nastavitev ni uspela", - "message": "Pri nastavljanju vaše nadzorne plošče je prišlo do napake. Poskusite znova." - }, - "helpers": { - "tooltip": "Pomočniki URL", - "description": "Ti pomočniki poskušajo uganiti URL za vaše storitve. Če ne deluje, preprosto ročno vnesite naslov." - } - }, - "urlTemplate": { - "label": "Osnovni URL", - "placeholder": "homelab.local ali 192.168.1.1", - "hint": "Vnesite svojo osnovno domeno ali IP za samodejno izpolnjevanje URL-jev integracij", - "warning": "Če zdaj nastavite osnovni URL, bodo vsi URL-ji integracij samodejno izpolnjeni, kar vam prihrani čas med nastavljanjem", - "preview": "npr. {example}", - "mode": { - "subdomain": "Pod-domena", - "hostPort": "Gostitelj:Vrata" - } - }, - "docker": { - "detected": "Docker zazna {count} storitev", - "detectedBreakdown": "{integrations} integracij · {apps} aplikacij", - "summary": "Homarr je zaznal {apps} aplikacij, od katerih ima {integrations} vgrajene integracije", - "appBadge": "Aplikacija", - "appSectionLabel": "Aplikacije za dodajanje na nadzorno ploščo ({count} izbranih)", - "scanning": "Skeniranje Dockerja ...", - "scanningDescription": "Iskanje tekočih vsebnikov v vašem sistemu" - } - }, - "finish": { - "title": "", - "subtitle": "", - "description": "", - "action": { - "goToBoard": "", - "createBoard": "", - "inviteUser": "", - "docs": "" - } - } - }, - "backToStart": "" - }, + "init": {}, "user": { "title": "Uporabniki", "name": "Uporabnik", @@ -3989,62 +3840,6 @@ "message": "Vsebina ni shranjena, želite nadaljevati?" } }, - "oldImport": { - "label": "", - "notification": { - "success": { - "title": "", - "message": "" - }, - "error": { - "title": "", - "message": "" - } - }, - "form": { - "file": { - "label": "", - "invalidError": "" - }, - "apps": { - "label": "Aplikacije", - "avoidDuplicates": { - "label": "", - "description": "" - }, - "onlyImportApps": { - "label": "", - "description": "" - } - }, - "name": { - "label": "" - }, - "screenSize": { - "label": "", - "description": "", - "option": { - "sm": "Majhna", - "md": "Srednja", - "lg": "Velika" - } - }, - "sidebarBehavior": { - "label": "", - "description": "", - "option": { - "lastSection": { - "label": "", - "description": "" - }, - "removeItems": { - "label": "", - "description": "" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "" }, - "importBoard": { - "label": "" - }, "newApp": { "label": "" }, diff --git a/packages/translation/src/lang/sv.json b/packages/translation/src/lang/sv.json index 3fbd0c899..b68493c20 100644 --- a/packages/translation/src/lang/sv.json +++ b/packages/translation/src/lang/sv.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Välkommen till Homarr", - "subtitle": "Låt oss komma igång med att sätta upp din installation av Homarr.", - "description": "För att komma igång, välj hur du vill ställa in din Homarr-installation.", - "action": { - "scratch": "Börja från början", - "importOldmarr": "Importera från Homarr äldre än version 1.0", - "restoreSqlite": "Återställ från SQLite-säkerhetskopia", - "restoreSqliteDescription": "Ladda upp en fullständig SQLite-databassäkerhetskopia från en tidigare Homarr-instans. Detta återställer alla tavlor, användare, appar, integrationer och inställningar.", - "backToOptions": "← Tillbaka till installationsalternativ" - } - }, - "import": { - "title": "Importera data", - "subtitle": "Du kan importera data från en befintlig installation av Homarr.", - "dropzone": { - "title": "Dra ZIP-filen hit eller klicka för att bläddra", - "description": "Den uppladdade ZIP-filen kommer att bearbetas och du kommer att kunna välja vad du vill importera" - }, - "fileInfo": { - "action": { - "change": "Byt fil" - } - }, - "importSettings": { - "title": "Importera inställningar", - "description": "Konfigurera hur importen skall genomföras" - }, - "boardSelection": { - "title": "Hittade {count} tavlor", - "description": "Välj alla tavlor med den storlek du vill importera", - "action": { - "selectAll": "Välj alla", - "unselectAll": "Avmarkera alla" - } - }, - "summary": { - "title": "Summering av importen", - "description": "I sammanfattningen nedan kan du se vad som kommer att importeras", - "action": { - "import": "Bekräfta importen och fortsätt" - }, - "entities": { - "apps": "Applikationer", - "boards": "Tavlor", - "integrations": "Integrationer", - "credentialUsers": "Användaruppgifter" - } - }, - "tokenModal": { - "title": "Ange 'Token' för importen", - "field": { - "token": { - "label": "Token", - "description": "Ange 'import-token' från din tidigare installation av Homarr" - } - }, - "notification": { - "error": { - "title": "Felaktig 'Token'", - "message": "Den 'Token' som du angivit är felaktig" - } - } - } - }, - "user": { - "title": "Administratör", - "subtitle": "Ange inloggningsuppgifter för administratören.", - "notification": { - "success": { - "title": "Användare har adderats", - "message": "Användaren adderades" - }, - "error": { - "title": "Misslyckades med att addera användaren" - } - } - }, - "group": { - "title": "Extern grupp", - "subtitle": "Ange vilken grupp som skall användas för externa användare.", - "form": { - "name": { - "label": "Gruppens namn", - "description": "Namnet måste matcha admin-grupp i extern tjänst" - } - } - }, - "settings": { - "title": "Inställningar", - "subtitle": "Konfigurera serverinställningar." - }, - "integrations": { - "title": "Integrationer", - "subtitle": "Välj de tjänster du använder.", - "description": "Välj de integrationer du vill ansluta. Vi konfigurerar automatiskt instrumentpanelen med matchande widgetar.", - "action": { - "continue": "Fortsätt", - "skip": "Hoppa över för nu" - }, - "configure": { - "progress": "{current} av {total}", - "skip": "Hoppa över denna integration", - "finishing": "Konfigurerar instrumentpanelen...", - "error": { - "title": "Konfigurationen misslyckades", - "message": "Något gick fel vid konfigurationen av instrumentpanelen. Försök igen." - }, - "helpers": { - "tooltip": "URL-hjälpare", - "description": "Dessa hjälpare gissar URL: en för dina tjänster. Om det inte fungerar, ange adressen manuellt." - } - }, - "urlTemplate": { - "label": "Bas-URL", - "placeholder": "homelab.local eller 192.168.1.1", - "hint": "Ange din basdomän eller IP för att automatiskt fylla i integrations-URL: er", - "warning": "Om du anger din bas-URL nu fylls alla integrations-URL: er i automatiskt, vilket sparar tid under installationen", - "preview": "t.ex. {example}", - "mode": { - "subdomain": "Subdomän", - "hostPort": "Värd:Port" - } - }, - "docker": { - "detected": "Docker upptäckte {count} tjänster", - "detectedBreakdown": "{integrations} integrationer · {apps} appar", - "summary": "Homarr upptäckte {apps} appar, varav {integrations} har inbyggda integrationer", - "appBadge": "Applikation", - "appSectionLabel": "Appar att lägga till på instrumentpanelen ({count} valda)", - "scanning": "Söker igenom Docker...", - "scanningDescription": "Letar efter körande behållare på ditt system" - } - }, - "finish": { - "title": "Slutför installationen", - "subtitle": "Du är redo att sätta igång!", - "description": "Du har slutfört installationen. Du kan nu börja använda Homarr. Välj din nästa åtgärd:", - "action": { - "goToBoard": "Gå till tavlan {name}", - "createBoard": "Addera din första tavla", - "inviteUser": "Bjud in andra användare", - "docs": "Läs dokumentationen" - } - } - }, - "backToStart": "Tillbaka till början" - }, + "init": {}, "user": { "title": "Användare", "name": "Användare", @@ -3989,62 +3840,6 @@ "message": "Du har ändringar som inte har sparats. Är du säker på att du vill fortsätta?" } }, - "oldImport": { - "label": "", - "notification": { - "success": { - "title": "", - "message": "Tavlan har importerats" - }, - "error": { - "title": "", - "message": "" - } - }, - "form": { - "file": { - "label": "", - "invalidError": "" - }, - "apps": { - "label": "Applikationer", - "avoidDuplicates": { - "label": "", - "description": "" - }, - "onlyImportApps": { - "label": "", - "description": "" - } - }, - "name": { - "label": "" - }, - "screenSize": { - "label": "", - "description": "I versioner äldre än version 1.0 fanns tre olika lägen, så du kunde välja antalet kolumner för varje skärmstorlek.", - "option": { - "sm": "Liten", - "md": "Mellan", - "lg": "Stor" - } - }, - "sidebarBehavior": { - "label": "", - "description": "", - "option": { - "lastSection": { - "label": "", - "description": "" - }, - "removeItems": { - "label": "Ta bort objekt", - "description": "" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "Addera en grupp" }, - "importBoard": { - "label": "" - }, "newApp": { "label": "Addera en ny applikation" }, diff --git a/packages/translation/src/lang/tr.json b/packages/translation/src/lang/tr.json index b15519f95..bb35012a8 100644 --- a/packages/translation/src/lang/tr.json +++ b/packages/translation/src/lang/tr.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Homarr'a Hoş Geldiniz", - "subtitle": "Homarr örneğinizi kurmaya başlayalım.", - "description": "Başlamak için lütfen Homarr örneğinizi nasıl kurmak istediğinizi seçin.", - "action": { - "scratch": "Sıfırdan Başla", - "importOldmarr": "Homarr 1.0'dan Önceki Yedeğinizi İçe Aktarın", - "restoreSqlite": "SQLite yedeklemesinden geri yükle", - "restoreSqliteDescription": "Önceki bir Homarr örneğinden tam SQLite veritabanı yedeklemesi yükleyin. Bu, tüm panoları, kullanıcıları, uygulamaları, entegrasyonları ve ayarları geri yükler.", - "backToOptions": "← Kurulum seçeneklerine geri dön" - } - }, - "import": { - "title": "Verileri İçe Aktar", - "subtitle": "Mevcut bir Homarr örneğinden veri içe aktarabilirsiniz.", - "dropzone": { - "title": "Zip dosyasını buraya sürükleyin veya göz atmak için tıklayın", - "description": "Seçtiğiniz zip işlenecek ve içe aktarmak istediğiniz verileri seçebileceksiniz" - }, - "fileInfo": { - "action": { - "change": "Dosyayı Değiştir" - } - }, - "importSettings": { - "title": "İçe Aktarma Ayarları", - "description": "İçe Aktarma İşlevlerini Yapılandır" - }, - "boardSelection": { - "title": "{count} Panel Bulundu", - "description": "İçe Aktarılacak Tüm Panellerin Boyutlarını Seçin", - "action": { - "selectAll": "Tümünü Seç", - "unselectAll": "Tüm Seçimi Kaldır" - } - }, - "summary": { - "title": "İçe Aktarma Özeti", - "description": "Aşağıdaki Özeti Kontrol Ederek İçe Aktarılacak İşlevleri Görebilirsiniz", - "action": { - "import": "İçe Aktarmayı Onayla ve Devam Et" - }, - "entities": { - "apps": "Uygulamalar", - "boards": "Paneller", - "integrations": "Entegrasyonlar", - "credentialUsers": "Kullanıcı Kimlik Bilgileri" - } - }, - "tokenModal": { - "title": "İçe Aktarma Anahtarını Girin", - "field": { - "token": { - "label": "Token Anahtarı", - "description": "Önceki Homarr Örneğinizden Aldığınız İçe Aktarma Anahtarı girin" - } - }, - "notification": { - "error": { - "title": "Token", - "message": "Girdiğiniz Token Anahtarı Geçersiz" - } - } - } - }, - "user": { - "title": "Yönetici", - "subtitle": "Yönetici kullanıcınız için kimlik bilgilerini girin.", - "notification": { - "success": { - "title": "Kullanıcı oluşturuldu", - "message": "Kullanıcı başarıyla oluşturuldu" - }, - "error": { - "title": "Kullanıcı oluşturma başarısız" - } - } - }, - "group": { - "title": "Harici grup", - "subtitle": "Harici kullanıcılar için kullanılacak grubu belirtin.", - "form": { - "name": { - "label": "Grup adı", - "description": "Ad, harici sağlayıcının yönetici grubuyla eşleşmelidir" - } - } - }, - "settings": { - "title": "Ayarlar", - "subtitle": "Sunucu ayarlarınızı yapılandırın." - }, - "integrations": { - "title": "Entegrasyonlar", - "subtitle": "Kullandığınız hizmetleri seçin.", - "description": "Bağlanmak istediğiniz entegrasyonları seçin. Panonuzu eşleşen widget'larla otomatik olarak kuracağız.", - "action": { - "continue": "Devam Et", - "skip": "Şimdilik atla" - }, - "configure": { - "progress": "{current} / {total}", - "skip": "Bu entegrasyonu atla", - "finishing": "Panonuz kuruluyor...", - "error": { - "title": "Kurulum başarısız oldu", - "message": "Panonuzu kurarken bir şeyler yanlış gitti. Lütfen tekrar deneyin." - }, - "helpers": { - "tooltip": "URL yardımcıları", - "description": "Bu yardımcılar, hizmetlerinizin URL'sini tahmin etmeye çalışır. Çalışmazsa, adresi manuel olarak yazın." - } - }, - "urlTemplate": { - "label": "Temel URL", - "placeholder": "homelab.local veya 192.168.1.1", - "hint": "Entegrasyon URL'lerini otomatik doldurmak için temel alan adınızı veya IP'nizi girin", - "warning": "Temel URL'nizi şimdi ayarlamak, tüm entegrasyon URL'lerini otomatik olarak dolduracak ve kurulum sırasında zaman kazandıracak", - "preview": "örn. {example}", - "mode": { - "subdomain": "Alt alan adı", - "hostPort": "Ana Bilgisayar:Port" - } - }, - "docker": { - "detected": "Docker {count} hizmet tespit etti", - "detectedBreakdown": "{integrations} entegrasyon · {apps} uygulama", - "summary": "Homarr {apps} uygulama tespit etti, bunların {integrations} tanesinde entegrasyon yerleşik", - "appBadge": "Uygulama", - "appSectionLabel": "Panoya eklenecek uygulamalar ({count} seçili)", - "scanning": "Docker taranıyor...", - "scanningDescription": "Sisteminizdeki çalışan konteynerler aranıyor" - } - }, - "finish": { - "title": "Kurulumu Bitir", - "subtitle": "Başlamaya Hazırsınız!", - "description": "Kurulum sürecini başarıyla tamamladınız. Artık Homarr'ı kullanmaya başlayabilirsiniz. Sonraki eyleminizi seçin:", - "action": { - "goToBoard": "{name} Paneline Git", - "createBoard": "İlk Panelinizi Oluşturun", - "inviteUser": "Yeni Kullanıcı Davet Et", - "docs": "Dokümanları Oku" - } - } - }, - "backToStart": "Başlangıca Geri Dön" - }, + "init": {}, "user": { "title": "Kullanıcılar", "name": "Kullanıcı", @@ -3989,62 +3840,6 @@ "message": "Kaydedilmemiş değişiklikleriniz var, çıkmak istediğinizden emin misiniz?" } }, - "oldImport": { - "label": "Homarr 1.0'dan önceki yedeğinizi içe aktarın", - "notification": { - "success": { - "title": "İçe aktarma başarılı", - "message": "Panel başarıyla içe aktarıldı" - }, - "error": { - "title": "İçe aktarma başarısız oldu", - "message": "Panel içe aktarılamadı, daha fazla ayrıntı için günlükleri kontrol edin" - } - }, - "form": { - "file": { - "label": "JSON Dosyası Seç", - "invalidError": "Geçersiz yapılandırma dosyası" - }, - "apps": { - "label": "Uygulamalar", - "avoidDuplicates": { - "label": "Tekrarlardan kaçının", - "description": "Aynı href'e sahip bir uygulamanın zaten mevcut olduğu uygulamaları yok sayar" - }, - "onlyImportApps": { - "label": "Yalnızca uygulamaları içe aktar", - "description": "Sadece uygulamaları ekler, panelin manuel olarak yeniden oluşturulması gerekir" - } - }, - "name": { - "label": "Panel adı" - }, - "screenSize": { - "label": "Ekran boyutu", - "description": "1.0'dan önceki sürümlerde üç farklı mod mevcuttu, yani her ekran boyutu için sütun miktarını seçebiliyordunuz.", - "option": { - "sm": "Küçük", - "md": "Orta", - "lg": "Büyük" - } - }, - "sidebarBehavior": { - "label": "Kenar çubuğu davranışı", - "description": "Kenar çubukları 1.0'da kaldırıldı, içlerindeki öğelere ne olacağını seçebilirsiniz.", - "option": { - "lastSection": { - "label": "Son bölüm", - "description": "Kenar çubuğu son bölümün altında görüntülenecektir" - }, - "removeItems": { - "label": "Öğeleri kaldır", - "description": "Kenar çubuğunda bulunan öğeler kaldırılacak" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Anında uygulama oluştur", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "Yeni Panel Oluştur" }, - "importBoard": { - "label": "Bir paneli içe aktar" - }, "newApp": { "label": "Yeni Uygulama Oluştur" }, diff --git a/packages/translation/src/lang/uk.json b/packages/translation/src/lang/uk.json index bfda23c7b..245c9a989 100644 --- a/packages/translation/src/lang/uk.json +++ b/packages/translation/src/lang/uk.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Ласкаво просимо до Homarr", - "subtitle": "Почнемо з налаштування вашого Homarr.", - "description": "Щоб почати, будь ласка, виберіть спосіб налаштування Вашого Homarr.", - "action": { - "scratch": "Почати з нуля", - "importOldmarr": "Імпорт з Homarr пре-1.0", - "restoreSqlite": "Відновлення з резервної копії SQLite", - "restoreSqliteDescription": "Завантажте повну резервну копію бази даних SQLite з попереднього екземпляра Homarr. Це відновить усі дошки, користувачів, програми, інтеграції та налаштування.", - "backToOptions": "Назад до параметрів налаштування" - } - }, - "import": { - "title": "Імпорт даних", - "subtitle": "Ви можете імпортувати дані з наявного екземпляра Homarr.", - "dropzone": { - "title": "Перетягніть zip-файл сюди або клікніть для пошуку", - "description": "Завантажений zip буде оброблений і ви зможете вибрати що ви хочете імпортувати" - }, - "fileInfo": { - "action": { - "change": "Змінити файл" - } - }, - "importSettings": { - "title": "Імпортувати налаштування", - "description": "Конфігурація імпорту" - }, - "boardSelection": { - "title": "Знайдено {count} дощок", - "description": "Оберіть всі дошки з розміром які ви хочете імпортувати", - "action": { - "selectAll": "Вибрати все", - "unselectAll": "Прибрати всі" - } - }, - "summary": { - "title": "Підсумок імпорту", - "description": "У наведеному нижче розділі ви можете побачити, що буде імпортовано", - "action": { - "import": "Підтвердить імпорт та продовжуйте" - }, - "entities": { - "apps": "Додатки", - "boards": "Дошки", - "integrations": "Інтеграції", - "credentialUsers": "Облікові записи" - } - }, - "tokenModal": { - "title": "Введіть токен імпорту", - "field": { - "token": { - "label": "Токен", - "description": "Введіть показаний токен імпорту з вашого попереднього екземпляра homarr" - } - }, - "notification": { - "error": { - "title": "Невірний токен", - "message": "Введений вами токен невірний" - } - } - } - }, - "user": { - "title": "Адміністратор", - "subtitle": "Вкажіть облікові дані для вашого адміністратора.", - "notification": { - "success": { - "title": "Користувача створено", - "message": "Користувача успішно створено" - }, - "error": { - "title": "Не вдалося створити користувача" - } - } - }, - "group": { - "title": "Зовнішня група", - "subtitle": "Вкажіть групу, яку слід використовувати для зовнішніх користувачів.", - "form": { - "name": { - "label": "Назва групи", - "description": "Ім'я має відповідати групі адміністраторів зовнішнього провайдера" - } - } - }, - "settings": { - "title": "Налаштування", - "subtitle": "Налаштування сервера." - }, - "integrations": { - "title": "Інтеграції", - "subtitle": "Виберіть сервіси, якими ви користуєтеся.", - "description": "Виберіть інтеграції, які ви хочете під'єднати. Ми автоматично налаштуємо вашу інформаційну панель з відповідними віджетами.", - "action": { - "continue": "Продовжити", - "skip": "Пропустити поки що" - }, - "configure": { - "progress": "{current} з {total}", - "skip": "Пропустити цю інтеграцію", - "finishing": "Налаштування панелі інструментів...", - "error": { - "title": "Не вдалося налаштувати", - "message": "Під час налаштування вашої інформаційної панелі сталася помилка. Спробуйте ще раз." - }, - "helpers": { - "tooltip": "Помічники URL-адрес", - "description": "Ці помічники намагаються вгадати URL-адресу ваших послуг. Якщо це не працює, просто введіть адресу вручну." - } - }, - "urlTemplate": { - "label": "Базовий URL", - "placeholder": "homelab.local або 192.168.1.1", - "hint": "Введіть свій базовий домен або IP-адресу для автоматичного заповнення URL-адрес інтеграції", - "warning": "Якщо встановити базову URL-адресу зараз, усі URL-адреси інтеграції будуть автоматично заповнені, що заощадить ваш час під час налаштування", - "preview": "напр. {example}", - "mode": { - "subdomain": "Субдомен", - "hostPort": "Хост:Порт" - } - }, - "docker": { - "detected": "Docker виявив {count} служб", - "detectedBreakdown": "{integrations} інтеграцій - {apps} програм", - "summary": "Homarr виявив {apps} програми, {integrations} з яких мають вбудовану інтеграцію", - "appBadge": "Додаток", - "appSectionLabel": "Додатки для додавання на інформаційну панель (вибрано {count})", - "scanning": "Сканування Docker...", - "scanningDescription": "Пошук запущених контейнерів у вашій системі" - } - }, - "finish": { - "title": "Завершити налаштування", - "subtitle": "Все готово!", - "description": "Ви завершили процес налаштування. Тепер ви можете почати використовувати Homarr. Виберіть наступну дію:", - "action": { - "goToBoard": "Перейти на {name} дошку", - "createBoard": "Створіть свою першу дошку", - "inviteUser": "Запросити інших користувачів", - "docs": "Ознайомитися з документацією" - } - } - }, - "backToStart": "На початок" - }, + "init": {}, "user": { "title": "Користувачі", "name": "Користувач", @@ -3999,62 +3850,6 @@ "message": "У вас є незбережені зміни. Ви впевнені, що хочете вийти?" } }, - "oldImport": { - "label": "Імпорт з homarr до версії 1.0.0", - "notification": { - "success": { - "title": "Успішно імпортовано", - "message": "Дошка успішно імпортована" - }, - "error": { - "title": "Помилка імпорту", - "message": "Дошка не може бути імпортована, перегляньте журнали для подальших деталей" - } - }, - "form": { - "file": { - "label": "Обрати файл JSON", - "invalidError": "Неправильний файл конфігурації" - }, - "apps": { - "label": "Додатки", - "avoidDuplicates": { - "label": "Уникати дублікатів", - "description": "Ігнорує додатки, якщо додаток з таким самим посиланням вже існує" - }, - "onlyImportApps": { - "label": "Імпортувати лише додатки", - "description": "Додавати лише додатки, дошка повинна бути перестворена вручну" - } - }, - "name": { - "label": "Назва дошки" - }, - "screenSize": { - "label": "Розмір екрану", - "description": "У версіях до 1.0 існувало три різні режими, тому ви могли вибрати кількість стовпців для кожного розміру екрана.", - "option": { - "sm": "Малий", - "md": "Середній", - "lg": "Великий" - } - }, - "sidebarBehavior": { - "label": "Налаштування бічної панелі", - "description": "Бічні панелі видалені з версії 1.0, ви можете вибрати, що має статися з елементами всередині них.", - "option": { - "lastSection": { - "label": "Останній розділ", - "description": "Бічна панель буде відображатися під останнім розділом" - }, - "removeItems": { - "label": "Видалити елементи", - "description": "Елементи, що містяться в бічній панелі, будуть видалені" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "Створити програму на льоту", @@ -5665,9 +5460,6 @@ "newBoard": { "label": "Створити нову дошку" }, - "importBoard": { - "label": "Імпортувати дошку" - }, "newApp": { "label": "Створити новий додаток" }, diff --git a/packages/translation/src/lang/vi.json b/packages/translation/src/lang/vi.json index 1d9615f0e..60ff80a91 100644 --- a/packages/translation/src/lang/vi.json +++ b/packages/translation/src/lang/vi.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "Chào mừng đến với Homarr", - "subtitle": "Hãy bắt đầu cài đặt ứng dụng Homarr của bạn.", - "description": "Để bắt đầu, hãy chọn cách bạn muốn cài đặt ứng dụng Homarr của mình.", - "action": { - "scratch": "Bắt đầu từ đầu", - "importOldmarr": "Nhập dữ liệu từ Homarr phiên bản trước 1.0", - "restoreSqlite": "Khôi phục từ bản sao lưu SQLite", - "restoreSqliteDescription": "Tải lên bản sao lưu cơ sở dữ liệu SQLite đầy đủ từ phiên bản Homarr trước. Thao tác này sẽ khôi phục tất cả bảng, người dùng, ứng dụng, tích hợp và cài đặt.", - "backToOptions": "← Quay lại tùy chọn thiết lập" - } - }, - "import": { - "title": "Nhập dữ liệu", - "subtitle": "Bạn có thể nhập dữ liệu từ một phiên bản Homarr có sẵn.", - "dropzone": { - "title": "Kéo tệp tin zip vào đây hoặc nhấp chuột để chọn", - "description": "Tệp tin zip đã tải lên sẽ được xử lý và bạn sẽ có thể chọn những gì bạn muốn nhập" - }, - "fileInfo": { - "action": { - "change": "Thay đổi tập tin" - } - }, - "importSettings": { - "title": "Nhập cài đặt", - "description": "Cấu hình hành vi nhập dữ liệu" - }, - "boardSelection": { - "title": "Tìm thấy {count} bảng", - "description": "Chọn tất cả các bảng với kích thước bạn muốn nhập", - "action": { - "selectAll": "Chọn tất cả", - "unselectAll": "Bỏ chọn tất cả" - } - }, - "summary": { - "title": "", - "description": "", - "action": { - "import": "Xác nhận nhập và tiếp tục" - }, - "entities": { - "apps": "Ứng dụng", - "boards": "Bảng", - "integrations": "Tích hợp", - "credentialUsers": "" - } - }, - "tokenModal": { - "title": "Nhập token nhập", - "field": { - "token": { - "label": "Mã thông báo", - "description": "Nhập token nhập được hiển thị từ phiên bản homarr trước đó của bạn" - } - }, - "notification": { - "error": { - "title": "Token không hợp lệ", - "message": "Token bạn nhập không đúng" - } - } - } - }, - "user": { - "title": "Người dùng quản trị", - "subtitle": "", - "notification": { - "success": { - "title": "Đã tạo người dùng", - "message": "Người dùng đã được tạo thành công" - }, - "error": { - "title": "Tạo người dùng không thành công" - } - } - }, - "group": { - "title": "", - "subtitle": "", - "form": { - "name": { - "label": "Tên nhóm", - "description": "" - } - } - }, - "settings": { - "title": "Cài đặt", - "subtitle": "Cấu hình cài đặt máy chủ." - }, - "integrations": { - "title": "Tích hợp", - "subtitle": "Chọn dịch vụ bạn dùng.", - "description": "Chọn các tích hợp bạn muốn kết nối. Chúng tôi sẽ tự động thiết lập bảng với widget phù hợp.", - "action": { - "continue": "Tiếp tục", - "skip": "Bỏ qua lúc này" - }, - "configure": { - "progress": "{current} của {total}", - "skip": "Bỏ qua tích hợp này", - "finishing": "Đang thiết lập bảng của bạn...", - "error": { - "title": "Thiết lập thất bại", - "message": "Đã xảy ra lỗi khi thiết lập bảng. Vui lòng thử lại." - }, - "helpers": { - "tooltip": "Trợ giúp URL", - "description": "Những trợ giúp này cố gắng đoán URL cho dịch vụ của bạn. Nếu không hiệu quả, hãy nhập địa chỉ thủ công." - } - }, - "urlTemplate": { - "label": "URL cơ sở", - "placeholder": "homelab.local hoặc 192.168.1.1", - "hint": "Nhập tên miền cơ sở hoặc IP để tự động điền URL tích hợp", - "warning": "Đặt URL cơ sở bây giờ sẽ tự động điền tất cả URL tích hợp, tiết kiệm thời gian thiết lập", - "preview": "ví dụ: {example}", - "mode": { - "subdomain": "Tên miền phụ", - "hostPort": "Máy:Cổng" - } - }, - "docker": { - "detected": "Docker phát hiện {count} dịch vụ", - "detectedBreakdown": "{integrations} tích hợp · {apps} ứng dụng", - "summary": "Homarr phát hiện {apps} ứng dụng, {integrations} trong đó có tích hợp sẵn", - "appBadge": "Ứng dụng", - "appSectionLabel": "Ứng dụng thêm vào bảng ({count} đã chọn)", - "scanning": "Đang quét Docker...", - "scanningDescription": "Đang tìm container đang chạy trên hệ thống của bạn" - } - }, - "finish": { - "title": "Hoàn tất cài đặt", - "subtitle": "Bạn đã sẵn sàng để tiếp tục!", - "description": "", - "action": { - "goToBoard": "", - "createBoard": "", - "inviteUser": "Mời người dùng khác", - "docs": "Đọc tài liệu hướng dẫn" - } - } - }, - "backToStart": "Trở lại từ đầu" - }, + "init": {}, "user": { "title": "Người dùng", "name": "Người dùng", @@ -3989,62 +3840,6 @@ "message": "Bạn có thay đổi chưa lưu, bạn có chắc chắn muốn rời đi không?" } }, - "oldImport": { - "label": "Nhập từ Homarr trước 1.0.0", - "notification": { - "success": { - "title": "Nhập thành công", - "message": "Bảng đã được nhập thành công" - }, - "error": { - "title": "Nhập không thành công", - "message": "Không thể nhập bảng, kiểm tra nhật ký để biết chi tiết" - } - }, - "form": { - "file": { - "label": "Chọn tệp JSON", - "invalidError": "Tệp cấu hình không hợp lệ" - }, - "apps": { - "label": "Ứng dụng", - "avoidDuplicates": { - "label": "Tránh trùng lặp", - "description": "Bỏ qua ứng dụng nếu đã có ứng dụng cùng href" - }, - "onlyImportApps": { - "label": "Chỉ nhập ứng dụng", - "description": "Chỉ thêm ứng dụng, cần tạo lại bảng thủ công" - } - }, - "name": { - "label": "Tên bảng" - }, - "screenSize": { - "label": "Kích thước màn hình", - "description": "", - "option": { - "sm": "Bé nhỏ", - "md": "Trung bình", - "lg": "Lớn" - } - }, - "sidebarBehavior": { - "label": "Hành vi thanh bên", - "description": "Thanh bên đã bị loại bỏ trong 1.0, bạn có thể chọn điều gì xảy ra với các mục bên trong.", - "option": { - "lastSection": { - "label": "Phần cuối cùng", - "description": "Thanh bên sẽ hiển thị bên dưới phần cuối cùng" - }, - "removeItems": { - "label": "Xóa mục", - "description": "Các mục trong thanh bên sẽ bị xóa" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "" }, - "importBoard": { - "label": "" - }, "newApp": { "label": "" }, diff --git a/packages/translation/src/lang/zh.json b/packages/translation/src/lang/zh.json index 310cbf845..e12298958 100644 --- a/packages/translation/src/lang/zh.json +++ b/packages/translation/src/lang/zh.json @@ -1,154 +1,5 @@ { - "init": { - "step": { - "start": { - "title": "歡迎使用 Homarr", - "subtitle": "讓我們開始設定 Homarr", - "description": "請選擇設定 Homarr 方式", - "action": { - "scratch": "重新開始", - "importOldmarr": "從 1.0 版本的 Homarr 匯入設定", - "restoreSqlite": "從 SQLite 備份還原", - "restoreSqliteDescription": "上傳先前 Homarr 執行個體的完整 SQLite 資料庫備份。這將還原所有面板、使用者、應用程式、整合和設定。", - "backToOptions": "← 返回設定選項" - } - }, - "import": { - "title": "匯入數據", - "subtitle": "從現有的 Homarr 匯入數據", - "dropzone": { - "title": "將 ZIP 壓縮檔拖曳至此處或點擊瀏覽", - "description": "上傳的壓縮檔將被處理,您可以選擇要匯入的內容" - }, - "fileInfo": { - "action": { - "change": "更改檔案" - } - }, - "importSettings": { - "title": "匯入設定", - "description": "設定匯入方式" - }, - "boardSelection": { - "title": "找到 {count} 個面板", - "description": "選擇要匯入的面板", - "action": { - "selectAll": "選擇全部", - "unselectAll": "取消全選" - } - }, - "summary": { - "title": "匯入概況", - "description": "於下方的概況中,可以看到即將匯入的內容", - "action": { - "import": "確認匯入並繼續" - }, - "entities": { - "apps": "應用程式", - "boards": "面板", - "integrations": "集成", - "credentialUsers": "使用者憑證" - } - }, - "tokenModal": { - "title": "輸入匯入密鑰", - "field": { - "token": { - "label": "密鑰", - "description": "輸入之前 Homarr 中顯示導入之密鑰匙" - } - }, - "notification": { - "error": { - "title": "無效密鑰", - "message": "輸入的密鑰無效" - } - } - } - }, - "user": { - "title": "管理員", - "subtitle": "指定管理員的使用者憑證", - "notification": { - "success": { - "title": "創建使用者", - "message": "使用者已成功創建" - }, - "error": { - "title": "使用者創建失敗" - } - } - }, - "group": { - "title": "外部使用者", - "subtitle": "指定用於外部使用者的用戶組", - "form": { - "name": { - "label": "用戶組名稱", - "description": "名稱必須與外部提供者的管理員匹配" - } - } - }, - "settings": { - "title": "設定", - "subtitle": "設定伺服器的設定" - }, - "integrations": { - "title": "整合", - "subtitle": "選取你使用的服務。", - "description": "選擇你要連接的整合。我們將會自動為你的儀表板設定相符的小工具。", - "action": { - "continue": "繼續", - "skip": "暫時略過" - }, - "configure": { - "progress": "{current}/{total}", - "skip": "略過此整合", - "finishing": "正在設定你的儀表板……", - "error": { - "title": "設定失敗", - "message": "設定儀表板時發生錯誤。請再試一次。" - }, - "helpers": { - "tooltip": "URL 幫手", - "description": "這些幫手會嘗試猜測你的服務 URL。如果無效,請手動輸入位址。" - } - }, - "urlTemplate": { - "label": "基礎 URL", - "placeholder": "homelab.local 或 192.168.1.1", - "hint": "輸入你的基礎網域或 IP 以自動填寫整合 URL", - "warning": "現在設定你的基礎 URL 將會自動填寫所有整合 URL,在設定過程中節省你的時間", - "preview": "例如 {example}", - "mode": { - "subdomain": "子網域", - "hostPort": "主機:連接埠" - } - }, - "docker": { - "detected": "Docker 偵測到 {count} 個服務", - "detectedBreakdown": "{integrations} 個整合 · {apps} 個應用程式", - "summary": "Homarr 偵測到 {apps} 個應用程式,其中 {integrations} 個具有內建整合", - "appBadge": "應用程式", - "appSectionLabel": "要新增至儀表板的應用程式(已選取 {count} 個)", - "scanning": "正在掃描 Docker……", - "scanningDescription": "正在尋找系統上執行的容器" - } - }, - "finish": { - "title": "設定完成", - "subtitle": "已設定完成", - "description": "已成功完成設定,開始使用 Homarr,請選擇下一步操作", - "action": { - "goToBoard": "進入 {name} 面板", - "createBoard": "創建第一個面板", - "inviteUser": "邀請其他使用者", - "docs": "閱讀說明書" - } - } - }, - "backToStart": "返回起點" - }, + "init": {}, "user": { "title": "使用者", "name": "使用者", @@ -3989,62 +3840,6 @@ "message": "尚未保存編輯,確認要離開嗎?" } }, - "oldImport": { - "label": "從 1.0.0 之前的 Homarr 匯入", - "notification": { - "success": { - "title": "匯入成功", - "message": "面板匯入成功" - }, - "error": { - "title": "匯入失敗", - "message": "無法匯入面板,請查閱 Logs 以取得更多詳情" - } - }, - "form": { - "file": { - "label": "選擇 JSON 檔案", - "invalidError": "無效的設定檔案" - }, - "apps": { - "label": "應用程式", - "avoidDuplicates": { - "label": "避免重複", - "description": "忽略已存在具有相同鏈結的應用程式" - }, - "onlyImportApps": { - "label": "僅匯入應用程式", - "description": "僅新增應用程式,面板需要手動重新創建" - } - }, - "name": { - "label": "面板名稱" - }, - "screenSize": { - "label": "螢幕大小", - "description": "在 1.0 之前的版本中,有三種不同的模式,因此可以根據螢幕大小選擇列的數量", - "option": { - "sm": "小號", - "md": "中等", - "lg": "大號" - } - }, - "sidebarBehavior": { - "label": "側邊欄行為", - "description": "側邊欄在 1.0 中被移除了,你可以選擇該怎麼處理其中的項目", - "option": { - "lastSection": { - "label": "最後區段", - "description": "側邊欄將顯示在最後區段的下方" - }, - "removeItems": { - "label": "移除項目", - "description": "側邊欄中包含的項目將被移除" - } - } - } - } - }, "quickCreateApp": { "modal": { "title": "快速建立應用程式", @@ -5655,9 +5450,6 @@ "newBoard": { "label": "創建新面板" }, - "importBoard": { - "label": "匯入面板" - }, "newApp": { "label": "創建新應用程式" }, diff --git a/packages/ui/package.json b/packages/ui/package.json index 52cd4c15d..171086637 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -13,6 +13,7 @@ }, "exports": { ".": "./index.ts", + "./integration-select-grid": "./src/integration-select-grid/index.ts", "./styles.css": "./src/styles.css", "./hooks": "./src/hooks/index.ts", "./icons": "./src/icons/index.ts" diff --git a/packages/ui/src/components/search-input.tsx b/packages/ui/src/components/search-input.tsx index b2a1ff7ae..30e49cbe5 100644 --- a/packages/ui/src/components/search-input.tsx +++ b/packages/ui/src/components/search-input.tsx @@ -1,7 +1,7 @@ "use client"; import type { ChangeEvent } from "react"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Loader, TextInput } from "@mantine/core"; import { useDebouncedCallback } from "@mantine/hooks"; @@ -10,25 +10,38 @@ import { IconSearch } from "@tabler/icons-react"; interface SearchInputProps { defaultValue?: string; placeholder: string; + ariaLabel: string; flexExpand?: boolean; } -export const SearchInput = ({ placeholder, defaultValue, flexExpand = false }: SearchInputProps) => { +export const SearchInput = ({ placeholder, ariaLabel, defaultValue, flexExpand = false }: SearchInputProps) => { // eslint-disable-next-line @typescript-eslint/unbound-method const { replace } = useRouter(); const pathName = usePathname(); const searchParams = useSearchParams(); const [loading, setLoading] = useState(false); + const [value, setValue] = useState(defaultValue ?? ""); + useEffect(() => { + setValue(defaultValue ?? ""); + setLoading(false); + }, [defaultValue]); const handleSearchDebounced = useDebouncedCallback((value: string) => { const params = new URLSearchParams(searchParams); - params.set("search", value.toString()); + const normalizedValue = value.trim(); + if (normalizedValue.length === 0) { + params.delete("search"); + } else { + params.set("search", normalizedValue); + } if (params.has("page")) params.set("page", "1"); // Reset page to 1 - replace(`${pathName}?${params.toString()}`); + const query = params.toString(); + replace(query ? `${pathName}?${query}` : pathName); setLoading(false); }, 250); const handleSearch = useCallback( (event: ChangeEvent) => { + setValue(event.currentTarget.value); setLoading(true); handleSearchDebounced(event.currentTarget.value); }, @@ -38,9 +51,10 @@ export const SearchInput = ({ placeholder, defaultValue, flexExpand = false }: S return ( } - defaultValue={defaultValue} + value={value} onChange={handleSearch} placeholder={placeholder} + aria-label={ariaLabel} style={{ flex: flexExpand ? "1" : undefined }} /> ); diff --git a/packages/ui/src/integration-select-grid/index.ts b/packages/ui/src/integration-select-grid/index.ts new file mode 100644 index 000000000..f50861875 --- /dev/null +++ b/packages/ui/src/integration-select-grid/index.ts @@ -0,0 +1,6 @@ +export { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared"; +export type { IntegrationGridItem } from "./integration-grid-shared"; +export { IntegrationMultiSelectGrid } from "./integration-multi-select-grid"; +export type { IntegrationMultiSelectGridProps } from "./integration-multi-select-grid"; +export { IntegrationSelectGrid } from "./integration-select-grid"; +export type { IntegrationSelectGridProps } from "./integration-select-grid"; diff --git a/packages/ui/src/integration-select-grid/integration-grid-shared.spec.ts b/packages/ui/src/integration-select-grid/integration-grid-shared.spec.ts new file mode 100644 index 000000000..bbea5e8b5 --- /dev/null +++ b/packages/ui/src/integration-select-grid/integration-grid-shared.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { integrationKinds } from "@homarr/definitions"; + +import { buildSortedIntegrations } from "./integration-grid-shared"; + +describe("buildSortedIntegrations", () => { + it("keeps every real integration available during onboarding", () => { + const kinds = buildSortedIntegrations({ onboarding: true }).map((integration) => integration.kind); + + expect(kinds).toHaveLength(integrationKinds.length - 1); + expect(kinds).not.toContain("mock"); + }); +}); diff --git a/packages/ui/src/integration-select-grid/integration-grid-shared.ts b/packages/ui/src/integration-select-grid/integration-grid-shared.ts new file mode 100644 index 000000000..ae6f96c5c --- /dev/null +++ b/packages/ui/src/integration-select-grid/integration-grid-shared.ts @@ -0,0 +1,77 @@ +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import { + featuredIntegrations, + getIntegrationName, + getWidgetKindsForIntegration, + hiddenFromOnboarding, + integrationDefs, + integrationKinds, +} from "@homarr/definitions"; + +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", + beszel: "integration.category.beszel", + search: "integration.category.search", + mediaTranscoding: "integration.category.mediaTranscoding", + networkController: "integration.category.networkController", + notifications: "integration.category.notifications", + firewall: "integration.category.firewall", + photoService: "integration.category.photoService", + notes: "integration.category.notes", + mediaMonitoring: "integration.category.mediaMonitoring", + speedtest: "integration.category.speedtest", + analytics: "integration.category.analytics", + vpn: "integration.category.vpn", + documents: "integration.category.documents", + mediaLibrary: "integration.category.mediaLibrary", + uptimeMonitoring: "integration.category.uptimeMonitoring", +}; + +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.enableMockIntegration && kind === "mock") return false; + return true; + }) + .map((kind) => ({ + kind, + name: getIntegrationName(kind), + categories: [...new Set(integrationDefs[kind].category.flat())] as string[], + widgets: getWidgetKindsForIntegration(kind), + })) + .toSorted((left, right) => { + const leftIndex = featuredIntegrations.indexOf(left.kind); + const rightIndex = featuredIntegrations.indexOf(right.kind); + if (leftIndex !== -1 && rightIndex !== -1) return leftIndex - rightIndex; + if (leftIndex !== -1) return -1; + if (rightIndex !== -1) return 1; + return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); + }); + +export const filterIntegrations = (items: IntegrationGridItem[], search: string) => { + const query = search.toLocaleLowerCase().trim(); + if (!query) return items; + return items.filter((item) => item.name.toLocaleLowerCase().includes(query)); +}; diff --git a/packages/ui/src/integration-select-grid/integration-multi-select-grid.tsx b/packages/ui/src/integration-select-grid/integration-multi-select-grid.tsx new file mode 100644 index 000000000..6ba014cec --- /dev/null +++ b/packages/ui/src/integration-select-grid/integration-multi-select-grid.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { Badge, Card, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; +import { IconBrandDocker, IconCheck, IconPuzzle } from "@tabler/icons-react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; + +import { IntegrationAvatar } from "../components/integration-avatar"; +import { SelectGridLayout, selectGridCardHeight } from "../select-grid-layout"; +import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared"; +import classes from "./integration-select-grid.module.css"; + +export 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)); + }, + [onSelectionChange, selectedKindsArray], + ); + const integrations = useMemo( + () => buildSortedIntegrations({ enableMockIntegration, onboarding }), + [enableMockIntegration, onboarding], + ); + const sorted = useMemo(() => { + if (!detectedKinds || detectedKinds.size === 0) return integrations; + return [ + ...integrations.filter((integration) => detectedKinds.has(integration.kind)), + ...integrations.filter((integration) => !detectedKinds.has(integration.kind)), + ]; + }, [detectedKinds, integrations]); + const filtered = useMemo(() => filterIntegrations(sorted, search), [search, sorted]); + + return ( + + {filtered.map((integration) => { + const isSelected = selectedKinds.has(integration.kind); + const isDetected = detectedKinds?.has(integration.kind) ?? false; + return ( + toggleKind(integration.kind)} + aria-label={integration.name} + aria-pressed={isSelected} + > + + + + + + {integration.name} + + {isSelected && } + + + {isDetected && ( + }> + {t("integration.grid.detected")} + + )} + + {integration.categories.slice(0, 2).map((category) => ( + + {categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category} + + ))} + + {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/packages/ui/src/integration-select-grid/integration-select-grid.module.css b/packages/ui/src/integration-select-grid/integration-select-grid.module.css new file mode 100644 index 000000000..191c751b7 --- /dev/null +++ b/packages/ui/src/integration-select-grid/integration-select-grid.module.css @@ -0,0 +1,26 @@ +.choice { + width: 100%; + border-radius: var(--mantine-radius-lg); + text-align: start; +} + +.choice:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 2px; +} + +.card { + transition: + background-color 150ms ease-out, + border-color 150ms ease-out; +} + +.choice:hover .card { + background-color: var(--mantine-color-default-hover); +} + +@media (prefers-reduced-motion: reduce) { + .card { + transition: none; + } +} diff --git a/packages/ui/src/integration-select-grid/integration-select-grid.module.d.css.ts b/packages/ui/src/integration-select-grid/integration-select-grid.module.d.css.ts new file mode 100644 index 000000000..94293cffd --- /dev/null +++ b/packages/ui/src/integration-select-grid/integration-select-grid.module.d.css.ts @@ -0,0 +1,2 @@ +declare const classes: Record; +export default classes; diff --git a/packages/ui/src/integration-select-grid/integration-select-grid.tsx b/packages/ui/src/integration-select-grid/integration-select-grid.tsx new file mode 100644 index 000000000..06e4743db --- /dev/null +++ b/packages/ui/src/integration-select-grid/integration-select-grid.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Badge, Card, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; +import { IconPuzzle } from "@tabler/icons-react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; + +import { IntegrationAvatar } from "../components/integration-avatar"; +import { SelectGridLayout, selectGridCardHeight } from "../select-grid-layout"; +import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared"; +import classes from "./integration-select-grid.module.css"; + +export 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)} + aria-label={integration.name} + > + + + + + + {integration.name} + + + + + + + ))} + {filtered.length === 0 && ( +
+ {t("common.noResults")} +
+ )} +
+ ); +}; + +const IntegrationGridMeta = ({ integration }: { integration: ReturnType[number] }) => { + const t = useI18n(); + return ( + + + {integration.categories.slice(0, 2).map((category) => ( + + {categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category} + + ))} + + {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")} + + )} + + ); +}; diff --git a/packages/ui/src/select-grid-layout.tsx b/packages/ui/src/select-grid-layout.tsx index 10f48f3df..a47fb88a3 100644 --- a/packages/ui/src/select-grid-layout.tsx +++ b/packages/ui/src/select-grid-layout.tsx @@ -4,7 +4,7 @@ import type { KeyboardEvent, ReactNode } from "react"; import { Input, ScrollArea, SimpleGrid, Stack } from "@mantine/core"; import { IconSearch } from "@tabler/icons-react"; -export const selectGridCols = { base: 2, xs: 3, sm: 4, md: 5 }; +export const selectGridCols = { base: 1, xs: 2, sm: 3, md: 4, lg: 5 }; export const selectGridCardHeight = 140; export const selectGridScrollMaxHeight = "70vh"; @@ -12,6 +12,7 @@ interface SelectGridLayoutProps { search: string; onSearchChange: (value: string) => void; placeholder: string; + ariaLabel: string; onSearchKeyDown?: (event: KeyboardEvent) => void; disableScroll?: boolean; disableAutoFocus?: boolean; @@ -22,6 +23,7 @@ export const SelectGridLayout = ({ search, onSearchChange, placeholder, + ariaLabel, onSearchKeyDown, disableScroll = false, disableAutoFocus = false, @@ -40,6 +42,7 @@ export const SelectGridLayout = ({ onChange={(event) => onSearchChange(event.currentTarget.value)} leftSection={} placeholder={placeholder} + aria-label={ariaLabel} data-autofocus={disableAutoFocus ? undefined : true} onKeyDown={onSearchKeyDown} /> diff --git a/packages/validation/src/onboarding.spec.ts b/packages/validation/src/onboarding.spec.ts new file mode 100644 index 000000000..9825f861e --- /dev/null +++ b/packages/validation/src/onboarding.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "vitest"; + +import { onboardingCreateIntegrationSchema, onboardingDiscoveredAppSchema } from "./onboarding"; + +describe("onboarding URL validation", () => { + test.each(["javascript:alert(1)", "data:text/html,hello", "file:///etc/passwd"])( + "rejects the %s scheme for integrations", + (url) => { + expect(onboardingCreateIntegrationSchema.safeParse({ name: "Unsafe", url }).success).toBe(false); + }, + ); + + test("accepts HTTP services and icon identifiers", () => { + expect( + onboardingDiscoveredAppSchema.safeParse({ + name: "Sonarr", + sourceId: "docker:sonarr", + href: "http://sonarr:8989", + pingUrl: "https://sonarr.example.com/ping", + iconUrl: "sonarr", + description: "TV automation", + }).success, + ).toBe(true); + }); + + test("accepts bounded integration metadata and rejects invalid metadata", () => { + expect( + onboardingCreateIntegrationSchema.safeParse({ + name: "Sonarr", + url: "http://sonarr:8989", + pingUrl: "https://sonarr.example.com/ping", + iconUrl: "https://icons.example/sonarr.svg", + description: "TV automation", + }).success, + ).toBe(true); + expect( + onboardingCreateIntegrationSchema.safeParse({ + name: "Sonarr", + url: "http://sonarr:8989", + pingUrl: "file:///etc/passwd", + description: "x".repeat(513), + }).success, + ).toBe(false); + }); + + test("rejects non-web app and ping URLs", () => { + expect( + onboardingDiscoveredAppSchema.safeParse({ + name: "Unsafe", + href: "javascript:alert(1)", + pingUrl: "file:///etc/passwd", + iconUrl: null, + }).success, + ).toBe(false); + }); +}); diff --git a/packages/validation/src/onboarding.ts b/packages/validation/src/onboarding.ts new file mode 100644 index 000000000..d9885a7ba --- /dev/null +++ b/packages/validation/src/onboarding.ts @@ -0,0 +1,62 @@ +import { z } from "zod/v4"; + +import { colorSchemes, onboardingLayoutPresets, widgetKinds } from "@homarr/definitions"; +import { supportedLanguages } from "@homarr/translation/languages"; + +import { boardNameSchema } from "./board"; + +const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/); +const httpUrlSchema = z.url().refine((value) => { + const protocol = new URL(value).protocol; + return protocol === "http:" || protocol === "https:"; +}, "Only HTTP and HTTPS URLs are permitted"); +const uniqueIdsSchema = z + .array(z.string().trim().min(1).max(255)) + .max(128) + .refine((ids) => new Set(ids).size === ids.length, { message: "IDs must be unique" }); + +export const onboardingCompleteSetupSchema = z.object({ + server: z.object({ + defaultLocale: z.enum(supportedLanguages), + defaultColorScheme: z.enum(colorSchemes), + analyticsEnabled: z.boolean().optional(), + }), + board: z.object({ + id: z.string().trim().min(1).max(255).optional(), + name: boardNameSchema, + primaryColor: hexColorSchema, + secondaryColor: hexColorSchema, + itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]), + layoutPreset: z.enum(onboardingLayoutPresets).default("balanced"), + leftSidebar: z.boolean().default(false), + rightSidebar: z.boolean().default(false), + }), + selectedIntegrationIds: uniqueIdsSchema.default([]), + selectedAppIds: uniqueIdsSchema.default([]), + selectedDockerSourceIds: uniqueIdsSchema.default([]), + selectedWidgetKinds: z + .array(z.enum(widgetKinds)) + .max(widgetKinds.length) + .refine((kinds) => new Set(kinds).size === kinds.length, { message: "Widget kinds must be unique" }) + .default([]), +}); + +export const onboardingCreateIntegrationSchema = z.object({ + name: z.string().trim().min(1).max(127), + url: httpUrlSchema, + sourceId: z.string().trim().min(1).max(512).optional(), + iconUrl: z.string().trim().min(1).max(2048).nullable().optional(), + description: z.string().trim().max(512).nullable().optional(), + pingUrl: httpUrlSchema.nullable().optional(), +}); + +export const onboardingDiscoveredAppSchema = z.object({ + sourceId: z.string().trim().min(1).max(512).optional(), + name: z.string().trim().min(1).max(127), + href: httpUrlSchema.nullable(), + pingUrl: httpUrlSchema.nullable().optional(), + iconUrl: z.string().trim().min(1).nullable(), + description: z.string().trim().max(512).nullable().optional(), +}); + +export type OnboardingCompleteSetupInput = z.infer; diff --git a/packages/validation/src/settings.ts b/packages/validation/src/settings.ts deleted file mode 100644 index e6a204329..000000000 --- a/packages/validation/src/settings.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from "zod/v4"; - -export const settingsInitSchema = z.object({ - analytics: z.object({ - enableGeneral: z.boolean(), - }), - crawlingAndIndexing: z.object({ - noIndex: z.boolean(), - noFollow: z.boolean(), - noTranslate: z.boolean(), - noSiteLinksSearchBox: z.boolean(), - }), -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87f061f13..85158904e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -495,6 +495,9 @@ catalogs: mermaid: specifier: ^11.15.0 version: 11.15.0 + motion: + specifier: ^13.0.0 + version: 13.0.0 mysql2: specifier: 3.22.6 version: 3.22.6 @@ -1048,12 +1051,9 @@ importers: '@homarr/notifications': specifier: workspace:^0.1.0 version: link:../../packages/notifications - '@homarr/old-import': + '@homarr/onboarding': specifier: workspace:^0.1.0 - version: link:../../packages/old-import - '@homarr/old-schema': - specifier: workspace:^0.1.0 - version: link:../../packages/old-schema + version: link:../../packages/onboarding '@homarr/redis': specifier: workspace:^0.1.0 version: link:../../packages/redis @@ -1496,12 +1496,6 @@ importers: '@homarr/integrations': specifier: workspace:^0.1.0 version: link:../integrations - '@homarr/old-import': - specifier: workspace:^0.1.0 - version: link:../old-import - '@homarr/old-schema': - specifier: workspace:^0.1.0 - version: link:../old-schema '@homarr/ping': specifier: workspace:^0.1.0 version: link:../ping @@ -1639,6 +1633,12 @@ importers: '@homarr/api': specifier: workspace:^0.1.0 version: link:../api + '@homarr/common': + specifier: workspace:^0.1.0 + version: link:../common + '@homarr/definitions': + specifier: workspace:^0.1.0 + version: link:../definitions react: specifier: 'catalog:' version: 19.2.7 @@ -2078,6 +2078,9 @@ importers: '@homarr/core': specifier: workspace:^0.1.0 version: link:../core + '@homarr/definitions': + specifier: workspace:^0.1.0 + version: link:../definitions dockerode: specifier: 'catalog:' version: 5.0.1(supports-color@10.2.2) @@ -2091,6 +2094,9 @@ importers: typescript: specifier: 'catalog:' version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)) packages/form: dependencies: @@ -2340,12 +2346,6 @@ importers: '@homarr/notifications': specifier: workspace:^0.1.0 version: link:../notifications - '@homarr/old-import': - specifier: workspace:^0.1.0 - version: link:../old-import - '@homarr/old-schema': - specifier: workspace:^0.1.0 - version: link:../old-schema '@homarr/translation': specifier: workspace:^0.1.0 version: link:../translation @@ -2403,32 +2403,29 @@ importers: specifier: 'catalog:' version: 7.0.2 - packages/old-import: + packages/onboarding: dependencies: + '@homarr/api': + specifier: workspace:^0.1.0 + version: link:../api + '@homarr/auth': + specifier: workspace:^0.1.0 + version: link:../auth '@homarr/common': specifier: workspace:^0.1.0 version: link:../common - '@homarr/core': - specifier: workspace:^0.1.0 - version: link:../core - '@homarr/db': - specifier: workspace:^0.1.0 - version: link:../db '@homarr/definitions': specifier: workspace:^0.1.0 version: link:../definitions '@homarr/form': specifier: workspace:^0.1.0 version: link:../form - '@homarr/modals': + '@homarr/forms-collection': specifier: workspace:^0.1.0 - version: link:../modals + version: link:../forms-collection '@homarr/notifications': specifier: workspace:^0.1.0 version: link:../notifications - '@homarr/old-schema': - specifier: workspace:^0.1.0 - version: link:../old-schema '@homarr/translation': specifier: workspace:^0.1.0 version: link:../translation @@ -2444,43 +2441,15 @@ importers: '@mantine/hooks': specifier: 'catalog:' version: 9.4.1(react@19.2.7) - adm-zip: + '@tabler/icons-react': specifier: 'catalog:' - version: 0.5.18 - next: + version: 3.44.0(react@19.2.7) + motion: specifier: 'catalog:' - version: 16.3.0-preview.9(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) + version: 13.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 - react-dom: - specifier: 'catalog:' - version: 19.2.7(react@19.2.7) - superjson: - specifier: 'catalog:' - version: 2.2.6 - zod: - specifier: 'catalog:' - version: 4.4.3 - zod-form-data: - specifier: 'catalog:' - version: 3.0.2(zod@4.4.3) - devDependencies: - '@homarr/tsconfig': - specifier: workspace:^0.1.0 - version: link:../../tooling/typescript - '@types/adm-zip': - specifier: 'catalog:' - version: 0.5.8 - typescript: - specifier: 'catalog:' - version: 7.0.2 - - packages/old-schema: - dependencies: - '@homarr/common': - specifier: workspace:^0.1.0 - version: link:../common zod: specifier: 'catalog:' version: 4.4.3 @@ -2488,9 +2457,15 @@ importers: '@homarr/tsconfig': specifier: workspace:^0.1.0 version: link:../../tooling/typescript + '@types/react': + specifier: 'catalog:' + version: 19.2.17 typescript: specifier: 'catalog:' version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)) packages/ping: dependencies: @@ -11917,6 +11892,17 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + framer-motion@13.0.0: + resolution: {integrity: sha512-nQGZXlsiigN48nzvE7AL1GLekql+etcphp8v+PQ0X04oxO20yVmV9rU9XQ25c136RdeIYoaWlKT9oAwvJza3mg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -13498,6 +13484,23 @@ packages: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} + motion-dom@13.0.0: + resolution: {integrity: sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==} + + motion-utils@13.0.0: + resolution: {integrity: sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==} + + motion@13.0.0: + resolution: {integrity: sha512-RYZjEpgHUCKjBNqjmUQzW93VJWLvEZGTPdKRFSqOdOl07IIMvz+WrsZmD1XXAkoFLruW/8bO1L7UF+ViQTxtLg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + mpd-parser@1.4.0: resolution: {integrity: sha512-blbA7XpAaOdC/PR0Tu8GiUxlx6CpBuRowQPYV7lhi9Yr9G9+dkPXIUjqjzc/NCn/uZZPopluaJlIYntIEI/VLw==} @@ -27268,6 +27271,15 @@ snapshots: fraction.js@5.3.4: {} + framer-motion@13.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + motion-dom: 13.0.0 + motion-utils: 13.0.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + fresh@0.5.2: {} fs-constants@1.0.0: {} @@ -29214,6 +29226,20 @@ snapshots: mkdirp@3.0.1: {} + motion-dom@13.0.0: + dependencies: + motion-utils: 13.0.0 + + motion-utils@13.0.0: {} + + motion@13.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + framer-motion: 13.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + mpd-parser@1.4.0: dependencies: '@babel/runtime': 7.29.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fd9fc4539..adffad833 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -166,6 +166,7 @@ catalog: mantine-datatable: ^9.3.1 mantine-react-table: npm:mantine-react-table-open@9.0.3 mermaid: ^11.15.0 + motion: ^13.0.0 maria2: ^0.4.1 mysql2: 3.22.6 next: 16.3.0-preview.9