diff --git a/PRODUCT.md b/PRODUCT.md index 4182ae451..9ce48eec3 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -1,9 +1,5 @@ # Product -## Register - -product - ## Users Homarr serves people and teams who run self-hosted applications and want one dependable dashboard for launching apps, monitoring services, and viewing live operational data. They use it across wall displays, desktops, tablets, and smaller devices, often returning to the same board many times per day. diff --git a/apps/docs/docs/getting-started/glossary.mdx b/apps/docs/docs/getting-started/glossary.mdx index ebfee3c87..989c71e1c 100644 --- a/apps/docs/docs/getting-started/glossary.mdx +++ b/apps/docs/docs/getting-started/glossary.mdx @@ -1,6 +1,6 @@ # Glossary -In the glossary, you'll learn the different components and their names of Homarr. +This glossary explains Homarr's main components and the terms used for them. | Term | Definition | | ----------- | --------------------------------------------------------------------------------------- | diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 2c0113419..2e8137a20 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -155,6 +155,7 @@ Resize tiles from any edge or corner handle. On a touch screen, press and hold the tile before moving it; resize handles support direct touch input. Keyboard users can focus a tile, press Enter or Space to start editing, use the arrow keys to move it, and use Shift plus the arrow keys to resize it. Press Enter, Space, or Escape to stop keyboard editing. +Keyboard moves and resizes apply immediately; Escape stops keyboard editing without undoing those changes. During a pointer or touch interaction, Escape cancels the move or resize and restores the layout from before that interaction. The **Move / resize item** action in the edit menu remains available for precise numeric input. diff --git a/apps/nextjs/src/components/board/items/item-move-modal.tsx b/apps/nextjs/src/components/board/items/item-move-modal.tsx index ba652d07d..1fad1cd96 100644 --- a/apps/nextjs/src/components/board/items/item-move-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-move-modal.tsx @@ -9,8 +9,9 @@ import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@ho import { useI18n } from "@homarr/translation/client"; import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types"; -import { getBoardLaneColumnCount, getLayoutRowCount } from "../layout"; +import { getLayoutRowCount } from "../layout"; import { resolvePinnedGridCollisions } from "../sections/grid/dnd"; +import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements"; import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions"; import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions"; @@ -117,7 +118,7 @@ export const ItemMoveModal = createModal(({ actions, innerProps }) = w: width, h: height, }; - const targetPlacements = getSectionPlacements(board, currentLayoutId, target.id).filter( + const targetPlacements = getSectionGridPlacements(board, currentLayoutId, target.id).filter( (placement) => placement.id !== entry.id, ); const resolvedTarget = resolvePinnedGridCollisions( @@ -137,7 +138,7 @@ export const ItemMoveModal = createModal(({ actions, innerProps }) = { layoutId: currentLayoutId, sectionId: sourceSectionId, - placements: getSectionPlacements(board, currentLayoutId, sourceSectionId).filter( + placements: getSectionGridPlacements(board, currentLayoutId, sourceSectionId).filter( (placement) => placement.id !== entry.id, ), }, @@ -284,52 +285,15 @@ export const getMoveTargets = ( })); }; -const getSectionPlacements = (board: Board, layoutId: string, sectionId: string): SectionGridPlacement[] => [ - ...board.items.flatMap((item) => { - const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId); - return layout?.sectionId === sectionId - ? [ - { - id: item.id, - type: "item" as const, - x: layout.xOffset, - y: layout.yOffset, - w: layout.width, - h: layout.height, - }, - ] - : []; - }), - ...board.sections.flatMap((section) => { - if (section.kind !== "container") return []; - const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId); - return layout?.parentSectionId === sectionId - ? [ - { - id: section.id, - type: "section" as const, - x: layout.xOffset, - y: layout.yOffset, - w: layout.width, - h: layout.height, - }, - ] - : []; - }), -]; - const getSectionColumnCount = (board: Board, layoutId: string, sectionId: string) => { const layout = board.layouts.find((candidate) => candidate.id === layoutId); if (!layout) throw new Error(`Layout "${layoutId}" was not found`); const section = board.sections.find((candidate) => candidate.id === sectionId); if (!section) throw new Error(`Section "${sectionId}" was not found`); - if (section.kind === "container") { - const sectionLayout = section.layouts.find((candidate) => candidate.layoutId === layoutId); - if (!sectionLayout) throw new Error(`Section "${sectionId}" has no current layout`); - return sectionLayout.width; - } - return getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset)); + const columnCount = getSectionGridColumnCount(board, layoutId, sectionId); + if (columnCount === null) throw new Error(`Section "${sectionId}" has no current layout`); + return columnCount; }; const getSectionMaxRowCount = (board: Board, layoutId: string, sectionId: string) => { @@ -343,7 +307,7 @@ const getSectionMaxRowCount = (board: Board, layoutId: string, sectionId: string const getEntryMinimumSize = (board: Board, layoutId: string, entry: MovableEntry) => { if (entry.type === "item") return { width: 1, height: 1 }; - const children = getSectionPlacements(board, layoutId, entry.id); + const children = getSectionGridPlacements(board, layoutId, entry.id); return { width: Math.max(1, ...children.map((child) => child.x + child.w)), height: Math.max(1, ...children.map((child) => child.y + child.h)), diff --git a/apps/nextjs/src/components/board/sections/container/actions/container-actions.spec.ts b/apps/nextjs/src/components/board/sections/container/actions/container-actions.spec.ts index 04f17354b..6dac9e6cc 100644 --- a/apps/nextjs/src/components/board/sections/container/actions/container-actions.spec.ts +++ b/apps/nextjs/src/components/board/sections/container/actions/container-actions.spec.ts @@ -58,7 +58,15 @@ describe("container actions", () => { const nestedItem = new ItemMockBuilder({ id: "nested-item" }) .addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1, width: 1, height: 1 }) .build(); - const board = new BoardMockBuilder().addSection(removed).addSection(child).addItem(nestedItem).build(); + const board = new BoardMockBuilder() + .addEmptySection({ id: "root" }) + .addSection(removed) + .addSection(child) + .addItem(nestedItem) + .build(); + const layout = board.layouts[0]; + if (!layout) throw new Error("Expected a board layout"); + layout.id = "layout"; const result = removeContainerCallback({ id: removed.id })(board); const flattenedChild = result.sections.find( @@ -78,7 +86,7 @@ describe("container actions", () => { }); }); - test("normalizes and resolves collisions while flattening malformed child geometry", () => { + test("normalizes, pins the top-left child, and resolves collisions while flattening malformed geometry", () => { const removed = new ContainerSectionMockBuilder({ id: "removed" }) .addLayout({ layoutId: "layout", parentSectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 2 }) .build(); @@ -111,7 +119,67 @@ describe("container actions", () => { ); expect(layouts.existing).toMatchObject({ xOffset: 0, yOffset: 0, width: 2, height: 1 }); - expect(layouts.a).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 1 }); - expect(layouts.b).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 1, width: 2, height: 1 }); + expect(layouts.a).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 1, width: 2, height: 1 }); + expect(layouts.b).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 1 }); + }); + + test("keeps the container when a child container has layout geometry that cannot be flattened", () => { + const removed = new ContainerSectionMockBuilder({ id: "removed" }) + .addLayout({ layoutId: "other-layout", parentSectionId: "root", xOffset: 3, yOffset: 4 }) + .build(); + const child = new ContainerSectionMockBuilder({ id: "child" }) + .addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2 }) + .build(); + const board = new BoardMockBuilder().addSection(removed).addSection(child).build(); + + const result = removeContainerCallback({ id: removed.id })(board); + + expect(result).toBe(board); + expect(result.sections).toContainEqual(removed); + }); + + test("keeps the container when a child item has layout geometry that cannot be flattened", () => { + const removed = new ContainerSectionMockBuilder({ id: "removed" }) + .addLayout({ layoutId: "other-layout", parentSectionId: "root", xOffset: 3, yOffset: 4 }) + .build(); + const item = new ItemMockBuilder({ id: "item" }) + .addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1 }) + .build(); + const board = new BoardMockBuilder().addSection(removed).addItem(item).build(); + + const result = removeContainerCallback({ id: removed.id })(board); + + expect(result).toBe(board); + expect(result.sections).toContainEqual(removed); + }); + + test("keeps the container when flattening would move a child back into the removed container", () => { + const removed = new ContainerSectionMockBuilder({ id: "removed" }) + .addLayout({ layoutId: "layout", parentSectionId: "removed", xOffset: 3, yOffset: 4 }) + .build(); + const item = new ItemMockBuilder({ id: "item" }) + .addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1 }) + .build(); + const board = new BoardMockBuilder().addSection(removed).addItem(item).build(); + + const result = removeContainerCallback({ id: removed.id })(board); + + expect(result).toBe(board); + expect(result.sections).toContainEqual(removed); + }); + + test("keeps the container when its destination is a child container", () => { + const removed = new ContainerSectionMockBuilder({ id: "removed" }) + .addLayout({ layoutId: "layout", parentSectionId: "child", xOffset: 3, yOffset: 4 }) + .build(); + const child = new ContainerSectionMockBuilder({ id: "child" }) + .addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2 }) + .build(); + const board = new BoardMockBuilder().addSection(removed).addSection(child).build(); + + const result = removeContainerCallback({ id: removed.id })(board); + + expect(result).toBe(board); + expect(result.sections).toContainEqual(removed); }); }); diff --git a/apps/nextjs/src/components/board/sections/container/actions/remove-container.ts b/apps/nextjs/src/components/board/sections/container/actions/remove-container.ts index 5e4a53b9c..8562d0f9a 100644 --- a/apps/nextjs/src/components/board/sections/container/actions/remove-container.ts +++ b/apps/nextjs/src/components/board/sections/container/actions/remove-container.ts @@ -1,7 +1,9 @@ -import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions"; - import type { Board, ContainerSection } from "~/app/[locale]/boards/_types"; import { resolvePinnedGridCollisions } from "~/components/board/sections/grid/dnd"; +import { + getSectionGridColumnCount, + getSectionGridPlacements, +} from "~/components/board/sections/grid/section-grid-placements"; import type { SectionGridPlacement } from "~/components/board/sections/grid/use-grid-layout-actions"; export interface RemoveContainerInput { @@ -16,6 +18,27 @@ export const removeContainerCallback = ); if (!sectionToRemove) return board; + const removedLayoutsById = new Map(sectionToRemove.layouts.map((layout) => [layout.layoutId, layout])); + const childLayoutIds = [ + ...board.sections.flatMap((section) => + section.kind === "container" + ? section.layouts.flatMap((layout) => + layout.parentSectionId === sectionToRemove.id ? [layout.layoutId] : [], + ) + : [], + ), + ...board.items.flatMap((item) => + item.layouts.flatMap((layout) => (layout.sectionId === sectionToRemove.id ? [layout.layoutId] : [])), + ), + ]; + const hasUnresolvableChildLayout = childLayoutIds.some((layoutId) => { + const removedLayout = removedLayoutsById.get(layoutId); + return ( + !removedLayout || !isValidFlattenDestination(board, sectionToRemove.id, layoutId, removedLayout.parentSectionId) + ); + }); + if (hasUnresolvableChildLayout) return board; + const flattened = { ...board, sections: board.sections @@ -31,7 +54,7 @@ export const removeContainerCallback = const removedSectionLayout = sectionToRemove.layouts.find( (layoutToRemove) => layoutToRemove.layoutId === layout.layoutId, ); - if (!removedSectionLayout) throw new Error("Layout not found"); + if (!removedSectionLayout) return layout; return { ...layout, @@ -50,7 +73,7 @@ export const removeContainerCallback = const removedSectionLayout = sectionToRemove.layouts.find( (layoutToRemove) => layoutToRemove.layoutId === layout.layoutId, ); - if (!removedSectionLayout) throw new Error("Layout not found"); + if (!removedSectionLayout) return layout; return { ...layout, @@ -65,6 +88,29 @@ export const removeContainerCallback = return resolveFlattenedLayouts(flattened, sectionToRemove, board); }; +const isValidFlattenDestination = ( + board: Board, + removedSectionId: string, + layoutId: string, + destinationSectionId: string, +) => { + const visited = new Set(); + let sectionId = destinationSectionId; + + while (true) { + if (sectionId === removedSectionId || visited.has(sectionId)) return false; + visited.add(sectionId); + + const section = board.sections.find((candidate) => candidate.id === sectionId); + if (!section) return false; + if (section.kind !== "container") return true; + + const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId); + if (!layout) return false; + sectionId = layout.parentSectionId; + } +}; + const resolveFlattenedLayouts = (board: Board, removed: ContainerSection, original: Board): Board => { const resolvedByLayoutAndId = new Map(); @@ -80,13 +126,13 @@ const resolveFlattenedLayouts = (board: Board, removed: ContainerSection, origin return layout?.parentSectionId === removed.id ? [section.id] : []; }), ]; - const pinnedId = movedIds.toSorted()[0]; - if (!pinnedId) continue; - - const columnCount = getParentColumnCount(board, removedLayout.layoutId, removedLayout.parentSectionId); + const columnCount = getSectionGridColumnCount(board, removedLayout.layoutId, removedLayout.parentSectionId); if (!columnCount) continue; - const placements = getSectionPlacements(board, removedLayout.layoutId, removedLayout.parentSectionId); - const pinned = placements.find((placement) => placement.id === pinnedId); + const placements = getSectionGridPlacements(board, removedLayout.layoutId, removedLayout.parentSectionId); + const movedIdSet = new Set(movedIds); + const pinned = placements + .filter((placement) => movedIdSet.has(placement.id)) + .toSorted((first, second) => first.y - second.y || first.x - second.x || first.id.localeCompare(second.id))[0]; if (!pinned) continue; for (const placement of resolvePinnedGridCollisions(placements, pinned, columnCount)) { @@ -133,47 +179,3 @@ const resolveFlattenedLayouts = (board: Board, removed: ContainerSection, origin ), }; }; - -const getSectionPlacements = (board: Board, layoutId: string, sectionId: string): SectionGridPlacement[] => [ - ...board.items.flatMap((item) => { - const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId); - return layout?.sectionId === sectionId - ? [ - { - id: item.id, - type: "item" as const, - x: layout.xOffset, - y: layout.yOffset, - w: layout.width, - h: layout.height, - }, - ] - : []; - }), - ...board.sections.flatMap((section) => { - if (section.kind !== "container") return []; - const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId); - return layout?.parentSectionId === sectionId - ? [ - { - id: section.id, - type: "section" as const, - x: layout.xOffset, - y: layout.yOffset, - w: layout.width, - h: layout.height, - }, - ] - : []; - }), -]; - -const getParentColumnCount = (board: Board, layoutId: string, sectionId: string) => { - const section = board.sections.find((candidate) => candidate.id === sectionId); - if (!section) return null; - if (section.kind === "container") { - return section.layouts.find((layout) => layout.layoutId === layoutId)?.width ?? null; - } - const layout = board.layouts.find((candidate) => candidate.id === layoutId); - return layout ? getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset)) : null; -}; diff --git a/apps/nextjs/src/components/board/sections/container/container-actions.ts b/apps/nextjs/src/components/board/sections/container/container-actions.ts index 7bbc975a8..c9545ef9d 100644 --- a/apps/nextjs/src/components/board/sections/container/container-actions.ts +++ b/apps/nextjs/src/components/board/sections/container/container-actions.ts @@ -1,8 +1,7 @@ import { useCallback } from "react"; -import type { z } from "zod/v4"; import { useUpdateBoard } from "@homarr/boards/updater"; -import type { containerSectionOptionsSchema } from "@homarr/validation/shared"; +import type { ContainerSectionOptions } from "@homarr/validation/shared"; import { addContainerCallback } from "./actions/add-container"; import type { RemoveContainerInput } from "./actions/remove-container"; @@ -10,7 +9,7 @@ import { removeContainerCallback } from "./actions/remove-container"; interface UpdateContainerOptions { containerId: string; - newOptions: z.infer; + newOptions: ContainerSectionOptions; } export const useContainerActions = () => { diff --git a/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx b/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx index d1caa633a..b52aba851 100644 --- a/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx +++ b/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx @@ -18,6 +18,7 @@ import { import type { GridPlacement } from "~/components/board/layout"; import { useSectionContext } from "../section-context"; import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd"; +import type { GridTransaction } from "./dnd"; import { useGridEditorRuntimeStatus } from "./grid-editor-runtime"; import type { SectionGridPlacement } from "./use-grid-layout-actions"; import { useGridLayoutActions } from "./use-grid-layout-actions"; @@ -109,19 +110,29 @@ export const FixedGridItem = ({ const current = placements.find((candidate) => candidate.id === item.id); if (!current) return; - const transaction = beginGridTransaction( - { - grids: [ - { - id: section.id, - columnCount, - maxRowCount, - placements, - }, - ], - }, - { activeId: current.id, sourceGridId: section.id }, - ); + let transaction: GridTransaction; + try { + transaction = beginGridTransaction( + { + grids: [ + { + id: section.id, + columnCount, + maxRowCount, + placements, + }, + ], + }, + { activeId: current.id, sourceGridId: section.id }, + ); + } catch { + keyboardGridRef.current = { + source: renderedPlacements, + current: renderedPlacements, + }; + setKeyboardEditing(false); + return; + } const delta = getKeyboardDelta(event.key); const result = event.shiftKey ? previewGridResize(transaction, { diff --git a/apps/nextjs/src/components/board/sections/grid/grid-depth.ts b/apps/nextjs/src/components/board/sections/grid/grid-depth.ts new file mode 100644 index 000000000..7228b39aa --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/grid-depth.ts @@ -0,0 +1,14 @@ +export const getGridDepth = (gridId: string, parentGridById: ReadonlyMap) => { + let depth = 0; + let currentGridId = gridId; + const visited = new Set([gridId]); + + while (true) { + const parentGridId = parentGridById.get(currentGridId); + if (!parentGridId || visited.has(parentGridId)) return depth; + + depth += 1; + visited.add(parentGridId); + currentGridId = parentGridId; + } +}; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css index 5c2b9211a..6c9a1f1a9 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -5,7 +5,7 @@ background-image: linear-gradient(to right, color-mix(in srgb, var(--mantine-color-dimmed) 18%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in srgb, var(--mantine-color-dimmed) 18%, transparent) 1px, transparent 1px); - background-size: 224px 224px; + background-size: var(--board-grid-pitch) var(--board-grid-pitch); border-radius: var(--mantine-radius-md); } diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx index 6123b5abf..d401fc0d5 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -21,10 +21,15 @@ import type { } from "@dnd-kit/react"; import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDroppable } from "@dnd-kit/react"; -import { useCurrentLayout } from "@homarr/boards/context"; +import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { useI18n } from "@homarr/translation/client"; -import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize } from "~/components/board/layout"; +import { + getLayoutRowCount, + getLogicalItemStyle, + getLogicalTrackSize, + LOGICAL_GRID_PITCH, +} from "~/components/board/layout"; import type { GridPlacement } from "~/components/board/layout"; import { useSectionContext } from "../section-context"; import { @@ -38,6 +43,7 @@ import { previewGridResize, } from "./dnd"; import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, TransactionalGridState } from "./dnd"; +import { getGridDepth } from "./grid-depth"; import { GridResizePreviewProvider } from "./grid-editor-runtime"; import { useBoardGridPortalHost } from "./grid-portal-host"; import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions"; @@ -119,6 +125,7 @@ interface ResizePreviewInput { interface BoardGridEditorContextValue { interaction: GridInteraction | null; registerGrid: (grid: RegisteredGrid) => () => void; + getDepth: (gridId: string) => number; isDropTargetEligible: (source: AbstractDraggable, targetGridId: string) => boolean; canAcceptDrop: (source: AbstractDraggable, targetGridId: string) => boolean; beginResize: (input: ResizeStartInput) => boolean; @@ -137,6 +144,7 @@ const useBoardGridEditor = () => { /** One provider coordinates every root and nested grid on the board. */ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { + const board = useRequiredBoard(); const currentLayoutId = useCurrentLayout(); const t = useI18n(); const { announce } = useBoardGridPortalHost(); @@ -147,6 +155,19 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { const collisionFrameRef = useRef(0); const [interaction, setInteraction] = useState(null); const [overlayZoom, setOverlayZoom] = useState(1); + const parentGridById = useMemo( + () => + new Map( + board.sections.map((section): [string, string | null] => [ + section.id, + section.kind === "container" + ? (section.layouts.find((layout) => layout.layoutId === currentLayoutId)?.parentSectionId ?? null) + : null, + ]), + ), + [board.sections, currentLayoutId], + ); + const getDepth = useCallback((gridId: string) => getGridDepth(gridId, parentGridById), [parentGridById]); const clearInteraction = useCallback(() => { window.cancelAnimationFrame(collisionFrameRef.current); @@ -512,11 +533,8 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { arePointerCoordinatesEqual(active.lastProcessedPointer, pointer); if (canCommitRenderedPreview) { - const activeAfterHitTest = activeRef.current; - if (activeAfterHitTest?.valid && activeAfterHitTest.targetGridId) { - commitActiveTransaction(renderedInteraction.state); - return; - } + commitActiveTransaction(renderedInteraction.state); + return; } updateDragPreview( @@ -596,6 +614,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { () => ({ interaction, registerGrid, + getDepth, isDropTargetEligible, canAcceptDrop, beginResize, @@ -611,6 +630,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { interaction, isDropTargetEligible, previewResize, + getDepth, registerGrid, ], ); @@ -671,12 +691,13 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { const GridCollisionSynchronizer = ({ interaction }: { interaction: GridInteraction | null }) => { const manager = useDragDropManager(); + const geometryKey = interaction ? getGridGeometryKey(interaction.state) : null; useLayoutEffect(() => { - if (!interaction || !manager) return; + if (!geometryKey || !manager) return; for (const target of manager.registry.droppables.value) target.refreshShape(); manager.collisionObserver.forceUpdate(true); - }, [interaction, manager]); + }, [geometryKey, manager]); return null; }; @@ -692,11 +713,11 @@ export default function GridEditor({ }: GridEditorProps) { const t = useI18n(); const { section, items, innerSections } = useSectionContext(); - const { interaction, registerGrid, isDropTargetEligible } = useBoardGridEditor(); + const { interaction, registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditor(); const gridRef = useRef(null); const parentGridId = "parentSectionId" in section ? section.parentSectionId : null; const ownerPlacementId = "parentSectionId" in section ? section.id : null; - const depth = getGridDepth(sectionId, parentGridId); + const depth = getDepth(sectionId); const previewGrid = interaction?.state.grids.find((grid) => grid.id === sectionId); const controlledById = useMemo(() => new Map(placements.map((placement) => [placement.id, placement])), [placements]); const renderPlacements = useMemo(() => { @@ -792,7 +813,13 @@ export default function GridEditor({ data-dnd-drop-target={isDropTarget ? "true" : "false"} data-dnd-drop-valid={isDropTarget ? String(targetPlacement !== null) : undefined} data-dnd-preview-revision={interaction?.targetGridId === sectionId ? interaction.previewRevision : undefined} - style={{ width: getLogicalTrackSize(columnCount), height: getLogicalTrackSize(renderedRowCount) }} + style={ + { + width: getLogicalTrackSize(columnCount), + height: getLogicalTrackSize(renderedRowCount), + "--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`, + } as CSSProperties + } > {renderPlacements.map((placement) => { const entry = entryById.get(placement.id); @@ -1123,7 +1150,7 @@ const getPointerTargetGrid = ( grids: Iterable, source: AbstractDraggable, pointer: { x: number; y: number }, - canAcceptDrop: (source: AbstractDraggable, targetGridId: string) => boolean, + isTargetEligible: (source: AbstractDraggable, targetGridId: string) => boolean, ) => Array.from(grids) .filter((grid) => { @@ -1135,7 +1162,7 @@ const getPointerTargetGrid = ( pointer.x <= rectangle.right && pointer.y >= rectangle.top && pointer.y <= rectangle.bottom && - canAcceptDrop(source, grid.id) + isTargetEligible(source, grid.id) ); }) .toSorted((first, second) => { @@ -1217,9 +1244,17 @@ const getGridTargetData = (target: { data: Record }): GridTarge return data.kind === GRID_TARGET_TYPE && data.sectionId ? (data as GridTargetData) : null; }; -const getGridDepth = (sectionId: string, parentGridId: string | null) => { - if (!parentGridId || sectionId === parentGridId) return 0; - const parent = document.querySelector(`[data-grid-section-id="${CSS.escape(parentGridId)}"]`); - const parentDepth = Number(parent?.dataset.gridDepth ?? 0); - return Number.isFinite(parentDepth) ? parentDepth + 1 : 1; -}; +const getGridGeometryKey = (state: TransactionalGridState) => + JSON.stringify( + state.grids + .map((grid) => ({ + id: grid.id, + columnCount: grid.columnCount, + maxRowCount: grid.maxRowCount, + parentGridId: grid.parentGridId, + placements: grid.placements + .map(({ id, x, y, w, h }) => ({ id, x, y, w, h })) + .toSorted((first, second) => first.id.localeCompare(second.id)), + })) + .toSorted((first, second) => first.id.localeCompare(second.id)), + ); diff --git a/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx b/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx index b45da998f..c2146ea50 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx @@ -1,7 +1,7 @@ "use client"; import type { PropsWithChildren } from "react"; -import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { RouterOutputs } from "@homarr/api"; @@ -33,6 +33,7 @@ const GridPortalHostContext = createContext(n * content. */ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => { + const board = useRequiredBoard(); const integrations = useIntegrations(); const containersRef = useRef>(new Map()); const [containers, setContainers] = useState>(() => new Map()); @@ -40,6 +41,10 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => { const announce = useCallback((message: string) => { setAnnouncement((previous) => ({ id: previous.id + 1, message })); }, []); + const liveEntryIds = useMemo( + () => new Set([...board.items.map((item) => item.id), ...board.sections.map((section) => section.id)]), + [board.items, board.sections], + ); const acquireContainer = useCallback((id: string) => { const existing = containersRef.current.get(id); @@ -53,9 +58,17 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => { return container; }, []); - // Keep acquired nodes for the host's lifetime. Cross-grid commits can - // transiently render neither shell, and detaching the node must not remount - // live widget state before the destination shell adopts it. + useEffect(() => { + // A cross-grid move can briefly have no shell, but the entry still exists + // on the board. Only remove containers whose board entity was deleted. + let didPrune = false; + for (const id of containersRef.current.keys()) { + if (liveEntryIds.has(id)) continue; + containersRef.current.delete(id); + didPrune = true; + } + if (didPrune) setContainers(new Map(containersRef.current)); + }, [liveEntryIds]); const value = useMemo( () => ({ diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid-placements.ts b/apps/nextjs/src/components/board/sections/grid/section-grid-placements.ts new file mode 100644 index 000000000..bd99a921d --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/section-grid-placements.ts @@ -0,0 +1,49 @@ +import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions"; + +import type { Board } from "~/app/[locale]/boards/_types"; +import type { SectionGridPlacement } from "./use-grid-layout-actions"; + +export const getSectionGridPlacements = (board: Board, layoutId: string, sectionId: string): SectionGridPlacement[] => [ + ...board.items.flatMap((item) => { + const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId); + return layout?.sectionId === sectionId + ? [ + { + id: item.id, + type: "item" as const, + x: layout.xOffset, + y: layout.yOffset, + w: layout.width, + h: layout.height, + }, + ] + : []; + }), + ...board.sections.flatMap((section) => { + if (section.kind !== "container") return []; + const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId); + return layout?.parentSectionId === sectionId + ? [ + { + id: section.id, + type: "section" as const, + x: layout.xOffset, + y: layout.yOffset, + w: layout.width, + h: layout.height, + }, + ] + : []; + }), +]; + +export const getSectionGridColumnCount = (board: Board, layoutId: string, sectionId: string): number | null => { + const section = board.sections.find((candidate) => candidate.id === sectionId); + if (!section) return null; + if (section.kind === "container") { + return section.layouts.find((layout) => layout.layoutId === layoutId)?.width ?? null; + } + + const layout = board.layouts.find((candidate) => candidate.id === layoutId); + return layout ? getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset)) : null; +}; diff --git a/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts index 72fee5967..2ae6fcf60 100644 --- a/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts +++ b/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts @@ -92,6 +92,8 @@ describe("fixed grid item behavior", () => { mocks.editMode = true; mocks.maxRowCount = null; getWeatherMock().advancedOptions.title = null; + getClockMock().xOffset = 1; + getClockMock().yOffset = 0; container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -200,6 +202,21 @@ describe("fixed grid item behavior", () => { }); }); + test("stops keyboard editing when the rendered grid state is invalid", () => { + getClockMock().xOffset = 0; + renderWeather(root); + + const entry = getEditorEntry(container); + startKeyboardEditing(entry); + act(() => entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }))); + + expect(entry.dataset.keyboardEditing).toBe("false"); + expect(mocks.commitSectionGrid).not.toHaveBeenCalled(); + expect(mocks.announce).toHaveBeenLastCalledWith( + "widget.weather.name, column 1, row 1: item.moveResize.keyboard.stopped", + ); + }); + test("uses the whole tile as the focusable move surface with a visual drag affordance", () => { getWeatherMock().advancedOptions.title = " Balcony forecast "; renderWeather(root); @@ -286,6 +303,12 @@ const getWeatherMock = () => { return item; }; +const getClockMock = () => { + const item = mocks.items[1]; + if (!item) throw new Error("Expected the clock item mock to exist"); + return item; +}; + const getEditorEntry = (container: HTMLElement) => { const entry = container.querySelector("[data-editor-grid-entry]"); if (!entry) throw new Error("Expected the fixed grid item to render an editor entry"); diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-depth.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-depth.spec.ts new file mode 100644 index 000000000..8f15f29b1 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/test/grid-depth.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "vitest"; + +import { getGridDepth } from "../grid-depth"; + +describe("grid depth", () => { + test("follows the registered parent chain for deeply nested grids", () => { + const parents = new Map([ + ["root", null], + ["first", "root"], + ["second", "first"], + ["third", "second"], + ]); + + expect(getGridDepth("root", parents)).toBe(0); + expect(getGridDepth("third", parents)).toBe(3); + }); + + test("stops safely when malformed parent data contains a cycle", () => { + const parents = new Map([ + ["first", "second"], + ["second", "first"], + ]); + + expect(getGridDepth("first", parents)).toBe(1); + }); +}); diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts index d1732a826..55ecae127 100644 --- a/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts +++ b/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts @@ -12,7 +12,7 @@ describe("board grid import boundary", () => { test("keeps every current dnd-kit runtime import inside the edit-only module", () => { const runtimeImports = getSourceFiles(nextjsSourceDirectory) .flatMap(getRuntimeImports) - .filter(({ specifier }) => CURRENT_DND_KIT_PACKAGES.has(specifier)) + .filter(({ specifier }) => isCurrentDndKitImport(specifier)) .map(({ filePath, kind, specifier }) => ({ file: toSourceRelativePath(filePath), kind, @@ -40,6 +40,11 @@ describe("board grid import boundary", () => { kind: "static", specifier: "@dnd-kit/dom", }, + { + file: "components/board/sections/grid/grid-editor.tsx", + kind: "static", + specifier: "@dnd-kit/dom/utilities", + }, { file: "components/board/sections/grid/grid-editor.tsx", kind: "static", @@ -104,7 +109,10 @@ describe("board grid import boundary", () => { }); }); -const CURRENT_DND_KIT_PACKAGES = new Set(["@dnd-kit/abstract", "@dnd-kit/collision", "@dnd-kit/dom", "@dnd-kit/react"]); +const CURRENT_DND_KIT_PACKAGES = ["@dnd-kit/abstract", "@dnd-kit/collision", "@dnd-kit/dom", "@dnd-kit/react"]; + +const isCurrentDndKitImport = (specifier: string) => + CURRENT_DND_KIT_PACKAGES.some((packageName) => specifier === packageName || specifier.startsWith(`${packageName}/`)); interface RuntimeImport { filePath: string; diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-portal-host.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-portal-host.spec.ts new file mode 100644 index 000000000..f5b95d969 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/test/grid-portal-host.spec.ts @@ -0,0 +1,90 @@ +import { act, createElement, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { BoardGridPortalHost, useBoardGridPortalHost } from "../grid-portal-host"; + +const mocks = vi.hoisted(() => ({ + board: { + items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "root" }] }], + sections: [], + }, +})); + +vi.mock("@homarr/auth/client", () => ({ + useIntegrations: () => undefined, +})); + +vi.mock("@homarr/boards/context", () => ({ + useCurrentLayout: () => "desktop", + useRequiredBoard: () => mocks.board, +})); + +describe("board grid portal host", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + mocks.board = { + items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "root" }] }], + sections: [], + }; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + test("keeps a portal across relocation and prunes it after permanent deletion", () => { + const acquired: HTMLElement[] = []; + + renderHost(root, true, 0, acquired); + expect(getContainerCount(container)).toBe("1"); + + mocks.board = { + items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "other" }] }], + sections: [], + }; + renderHost(root, true, 1, acquired); + expect(getContainerCount(container)).toBe("1"); + expect(new Set(acquired).size).toBe(1); + + mocks.board = { items: [], sections: [] }; + renderHost(root, false, 2, acquired); + expect(getContainerCount(container)).toBe("0"); + }); +}); + +const renderHost = (root: Root, acquire: boolean, revision: number, acquired: HTMLElement[]) => { + act(() => { + root.render( + createElement(BoardGridPortalHost, null, createElement(PortalRegistryProbe, { acquire, revision, acquired })), + ); + }); +}; + +const PortalRegistryProbe = ({ + acquire, + revision, + acquired, +}: { + acquire: boolean; + revision: number; + acquired: HTMLElement[]; +}) => { + const portalHost = useBoardGridPortalHost(); + + useLayoutEffect(() => { + if (acquire) acquired.push(portalHost.acquireContainer("weather")); + }, [acquire, acquired, portalHost, revision]); + + return createElement("span", { "data-container-count": String(portalHost.containers.size) }); +}; + +const getContainerCount = (container: HTMLElement) => + container.querySelector("[data-container-count]")?.dataset.containerCount; diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts index b183840e6..4479dc264 100644 --- a/e2e/board-grid.spec.ts +++ b/e2e/board-grid.spec.ts @@ -465,7 +465,6 @@ describe("Board grid", () => { await expect(containerSection.locator("xpath=ancestor::aside[1]")).toHaveAttribute("data-board-gutter", "left"); await editToggle.click(); await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3); - const mainGridBox = await expectBoundingBoxAsync(mainGrid); await dragHandleToGridPositionAsync(page, containerGrip, containerSection, mainGrid, 2, 0, canvasScale); await expect(containerSection.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute( "data-grid-section-id", @@ -481,6 +480,7 @@ describe("Board grid", () => { await secondItem.locator("[data-grid-item-content]").evaluate((element) => { element.setAttribute("data-e2e-portal-instance", "second-item-content"); }); + const mainGridBox = await expectBoundingBoxAsync(mainGrid); await dragLocatorToAsync( page, secondItem, diff --git a/e2e/shared/create-homarr-container.ts b/e2e/shared/create-homarr-container.ts index d1e2cee7c..80e5646e0 100644 --- a/e2e/shared/create-homarr-container.ts +++ b/e2e/shared/create-homarr-container.ts @@ -10,8 +10,9 @@ export const createHomarrContainer = ( }; } = {}, ) => { - const image = process.env.HOMARR_E2E_IMAGE?.trim() || "homarr-e2e"; - if (!process.env.CI) { + const configuredImage = process.env.HOMARR_E2E_IMAGE?.trim(); + const image = configuredImage || "homarr-e2e"; + if (!process.env.CI && !configuredImage) { throw new Error(`This test should only be run in CI or with a local Homarr image (configured: '${image}')`); } diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 7c8cc6627..daaa44d0b 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -915,24 +915,24 @@ export const boardRouter = createTRPCRouter({ })), ); - if (addedSections.some((section) => section.kind === "container")) { - await transaction.insert(schema.sectionLayouts).values( - addedSections - .filter((section) => section.kind === "container") - .flatMap((section) => - section.layouts.map( - (sectionLayout): InferInsertModel => ({ - layoutId: sectionLayout.layoutId, - sectionId: section.id, - parentSectionId: sectionLayout.parentSectionId, - height: sectionLayout.height, - width: sectionLayout.width, - xOffset: sectionLayout.xOffset, - yOffset: sectionLayout.yOffset, - }), - ), - ), + const sectionLayoutsToInsert = addedSections + .filter((section) => section.kind === "container") + .flatMap((section) => + section.layouts.map( + (sectionLayout): InferInsertModel => ({ + layoutId: sectionLayout.layoutId, + sectionId: section.id, + parentSectionId: sectionLayout.parentSectionId, + height: sectionLayout.height, + width: sectionLayout.width, + xOffset: sectionLayout.xOffset, + yOffset: sectionLayout.yOffset, + }), + ), ); + + if (sectionLayoutsToInsert.length > 0) { + await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert); } } @@ -1115,27 +1115,24 @@ export const boardRouter = createTRPCRouter({ ) .run(); - if (addedSections.some((section) => section.kind === "container")) { - transaction - .insert(sectionLayouts) - .values( - addedSections - .filter((section) => section.kind === "container") - .flatMap((section) => - section.layouts.map( - (sectionLayout): InferInsertModel => ({ - layoutId: sectionLayout.layoutId, - sectionId: section.id, - parentSectionId: sectionLayout.parentSectionId, - height: sectionLayout.height, - width: sectionLayout.width, - xOffset: sectionLayout.xOffset, - yOffset: sectionLayout.yOffset, - }), - ), - ), - ) - .run(); + const sectionLayoutsToInsert = addedSections + .filter((section) => section.kind === "container") + .flatMap((section) => + section.layouts.map( + (sectionLayout): InferInsertModel => ({ + layoutId: sectionLayout.layoutId, + sectionId: section.id, + parentSectionId: sectionLayout.parentSectionId, + height: sectionLayout.height, + width: sectionLayout.width, + xOffset: sectionLayout.xOffset, + yOffset: sectionLayout.yOffset, + }), + ), + ); + + if (sectionLayoutsToInsert.length > 0) { + transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run(); } } diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index dd0df3e2c..0e77b758f 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -1011,6 +1011,43 @@ describe("saveBoard should save full board", () => { height: 5, }); }); + it("should add a container without layouts", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); + const { boardId, sectionId: rootSectionId } = await createFullBoardAsync(db, "default"); + const containerId = createId(); + + await caller.saveBoard({ + id: boardId, + sections: [ + { + id: rootSectionId, + kind: "empty", + xOffset: 0, + yOffset: 0, + }, + { + id: containerId, + kind: "container", + collapsed: false, + options: {}, + layouts: [], + }, + ], + items: [], + }); + + expect( + await db.query.sections.findFirst({ + where: eq(sections.id, containerId), + }), + ).toMatchObject({ id: containerId, kind: "container" }); + expect( + await db.query.sectionLayouts.findMany({ + where: eq(sectionLayouts.sectionId, containerId), + }), + ).toEqual([]); + }); it("should add item when present in input", async () => { const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); const db = createDb(); diff --git a/packages/translation/src/lang/ca.json b/packages/translation/src/lang/ca.json index 2a7bc6d94..e7fedee84 100644 --- a/packages/translation/src/lang/ca.json +++ b/packages/translation/src/lang/ca.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Nova secció dinàmica", - "remove": "Elimina la secció dinàmica" + "create": "Nou contenidor", + "remove": "Elimina el contenidor" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Elimina la secció dinàmica", - "message": "Esteu segur que voleu eliminar aquesta secció dinàmica? Els elements es mouran a la mateixa ubicació dins la secció pare." + "title": "Elimina el contenidor", + "message": "Esteu segur que voleu eliminar aquest contenidor? Els elements es mouran a la mateixa ubicació dins la secció pare." } } }, diff --git a/packages/translation/src/lang/cn.json b/packages/translation/src/lang/cn.json index aa51540a0..ec71c2475 100644 --- a/packages/translation/src/lang/cn.json +++ b/packages/translation/src/lang/cn.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "新建动态部分", - "remove": "删除动态部分" + "create": "新建容器", + "remove": "删除容器" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "删除动态部分", - "message": "你确定你要删除这个动态部分?项目将被移动到父节的相同位置。" + "title": "删除容器", + "message": "你确定要删除这个容器吗?项目将被移动到父级区域的相同位置。" } } }, diff --git a/packages/translation/src/lang/cs.json b/packages/translation/src/lang/cs.json index 97eb51a77..33186922e 100644 --- a/packages/translation/src/lang/cs.json +++ b/packages/translation/src/lang/cs.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Nová dynamická sekce", - "remove": "Odstranit dynamickou sekci" + "create": "Nový kontejner", + "remove": "Odstranit kontejner" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Odstranit dynamickou sekci", - "message": "Jste si jisti, že chcete odstranit tuto dynamickou sekci? Položky budou přesunuty na stejné místo v nadřazené sekci." + "title": "Odstranit kontejner", + "message": "Jste si jisti, že chcete odstranit tento kontejner? Položky budou přesunuty na stejné místo v nadřazené sekci." } } }, diff --git a/packages/translation/src/lang/da.json b/packages/translation/src/lang/da.json index 159a44909..86cd81db0 100644 --- a/packages/translation/src/lang/da.json +++ b/packages/translation/src/lang/da.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Ny dynamisk sektion", - "remove": "Fjern dynamisk sektion" + "create": "Ny container", + "remove": "Fjern container" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Fjern dynamisk sektion", - "message": "Er du sikker på at du vil fjerne dette dynamiske afsnit? Elementer vil blive flyttet på samme sted i det overordnede afsnit." + "title": "Fjern container", + "message": "Er du sikker på, at du vil fjerne denne container? Elementer vil blive flyttet til samme sted i det overordnede afsnit." } } }, diff --git a/packages/translation/src/lang/de-CH.json b/packages/translation/src/lang/de-CH.json index d31a11ea6..5fa4dd38f 100644 --- a/packages/translation/src/lang/de-CH.json +++ b/packages/translation/src/lang/de-CH.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Neuer dynamischer Abschnitt", - "remove": "Dynamischen Abschnitt entfernen" + "create": "Neuer Container", + "remove": "Container entfernen" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Dynamischen Abschnitt entfernen", - "message": "Möchten Sie diesen dynamischen Abschnitt wirklich entfernen? Die Elemente werden an die gleiche Position im übergeordneten Abschnitt verschoben." + "title": "Container entfernen", + "message": "Möchten Sie diesen Container wirklich entfernen? Die Elemente werden an die gleiche Position im übergeordneten Bereich verschoben." } } }, diff --git a/packages/translation/src/lang/de.json b/packages/translation/src/lang/de.json index c8d6262b1..bbf8b7522 100644 --- a/packages/translation/src/lang/de.json +++ b/packages/translation/src/lang/de.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Neuer dynamischer Abschnitt", - "remove": "Dynamischen Abschnitt entfernen" + "create": "Neuer Container", + "remove": "Container entfernen" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Dynamischen Abschnitt entfernen", - "message": "Möchten Sie diesen dynamischen Abschnitt wirklich entfernen? Die Elemente werden an die gleiche Position im übergeordneten Abschnitt verschoben." + "title": "Container entfernen", + "message": "Möchten Sie diesen Container wirklich entfernen? Die Elemente werden an die gleiche Position im übergeordneten Bereich verschoben." } } }, diff --git a/packages/translation/src/lang/el.json b/packages/translation/src/lang/el.json index 4ffb396f4..45222fc27 100644 --- a/packages/translation/src/lang/el.json +++ b/packages/translation/src/lang/el.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "", - "remove": "" + "create": "Νέο κοντέινερ", + "remove": "Κατάργηση κοντέινερ" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "", - "message": "" + "title": "Κατάργηση κοντέινερ", + "message": "Είστε βέβαιοι ότι θέλετε να καταργήσετε αυτό το κοντέινερ; Τα στοιχεία θα μετακινηθούν στην ίδια θέση στη γονική ενότητα." } } }, diff --git a/packages/translation/src/lang/es.json b/packages/translation/src/lang/es.json index c848e8de4..1356cfba4 100644 --- a/packages/translation/src/lang/es.json +++ b/packages/translation/src/lang/es.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Nueva sección dinámica", - "remove": "Eliminar sección dinámica" + "create": "Nuevo contenedor", + "remove": "Eliminar contenedor" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Eliminar sección dinámica", - "message": "¿Estás seguro de que deseas eliminar esta sección dinámica? Los artículos se mantendrán en la misma ubicación." + "title": "Eliminar contenedor", + "message": "¿Estás seguro de que deseas eliminar este contenedor? Los elementos se mantendrán en la misma ubicación." } } }, diff --git a/packages/translation/src/lang/et.json b/packages/translation/src/lang/et.json index 605128bce..bac350849 100644 --- a/packages/translation/src/lang/et.json +++ b/packages/translation/src/lang/et.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Uus dünaamiline sektsioon", - "remove": "Eemalda dünaamiline sektsioon" + "create": "Uus konteiner", + "remove": "Eemalda konteiner" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Eemalda dünaamiline sektsioon", - "message": "Kas oled kindel, et soovid selle dünaamilise sektsiooni eemaldada? Üksused teisaldatakse ülemsektsiooni samasse asukohta." + "title": "Eemalda konteiner", + "message": "Kas oled kindel, et soovid selle konteineri eemaldada? Üksused teisaldatakse ülemsektsiooni samasse asukohta." } } }, diff --git a/packages/translation/src/lang/fi.json b/packages/translation/src/lang/fi.json index 8e5f413f9..c158ae4e6 100644 --- a/packages/translation/src/lang/fi.json +++ b/packages/translation/src/lang/fi.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Uusi dynaaminen osio", - "remove": "" + "create": "Uusi kontti", + "remove": "Poista kontti" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "", - "message": "Haluatko varmasti poistaa tämän dynaamisen osion? Kohteet siirretään samaan paikkaan yläosiossa." + "title": "Poista kontti", + "message": "Haluatko varmasti poistaa tämän kontin? Kohteet siirretään samaan paikkaan yläosiossa." } } }, diff --git a/packages/translation/src/lang/fr.json b/packages/translation/src/lang/fr.json index 6f50ad8d5..f7a9a92ec 100644 --- a/packages/translation/src/lang/fr.json +++ b/packages/translation/src/lang/fr.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Nouvelle section dynamique", - "remove": "Supprimer la section dynamique" + "create": "Nouveau conteneur", + "remove": "Supprimer le conteneur" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Supprimer la section dynamique", - "message": "Êtes-vous sûr de vouloir supprimer cette section dynamique ? Les éléments seront déplacés au même emplacement dans la section parente." + "title": "Supprimer le conteneur", + "message": "Êtes-vous sûr de vouloir supprimer ce conteneur ? Les éléments seront déplacés au même emplacement dans la section parente." } } }, diff --git a/packages/translation/src/lang/he.json b/packages/translation/src/lang/he.json index 5f4ce7ee3..201bc6f91 100644 --- a/packages/translation/src/lang/he.json +++ b/packages/translation/src/lang/he.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "מדור דינמי חדש", - "remove": "הסר קטע דינמי" + "create": "מיכל חדש", + "remove": "הסר מיכל" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "הסר קטע דינמי", - "message": "האם אתה בטוח שברצונך להסיר את הקטע הדינמי הזה? פריטים יועברו לאותו מיקום במדור האב." + "title": "הסר מיכל", + "message": "האם אתה בטוח שברצונך להסיר את המיכל הזה? פריטים יועברו לאותו מיקום במדור האב." } } }, diff --git a/packages/translation/src/lang/hr.json b/packages/translation/src/lang/hr.json index 9f751672a..3b92e69c6 100644 --- a/packages/translation/src/lang/hr.json +++ b/packages/translation/src/lang/hr.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Nova dinamička sekcija", - "remove": "Ukloni dinamičku sekciju" + "create": "Novi kontejner", + "remove": "Ukloni kontejner" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Ukloni dinamičku sekciju", - "message": "Jeste li sigurni da želite ukloniti ovu dinamičku sekciju? Stavke će biti premještene na isto mjesto u nadređenoj sekciji." + "title": "Ukloni kontejner", + "message": "Jeste li sigurni da želite ukloniti ovaj kontejner? Stavke će biti premještene na isto mjesto u nadređenoj sekciji." } } }, diff --git a/packages/translation/src/lang/hu.json b/packages/translation/src/lang/hu.json index 06b310d3b..9474a74b1 100644 --- a/packages/translation/src/lang/hu.json +++ b/packages/translation/src/lang/hu.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Új dinamikus szakasz", - "remove": "A dinamikus szakasz eltávolítása" + "create": "Új konténer", + "remove": "A konténer eltávolítása" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Dinamikus szakasz eltávolítása", - "message": "Biztosan el akarja távolítani ezt a dinamikus részt? Az elemek a szülő rész ugyanazon helyére kerülnek át." + "title": "Konténer eltávolítása", + "message": "Biztosan el akarja távolítani ezt a konténert? Az elemek a szülő rész ugyanazon helyére kerülnek át." } } }, diff --git a/packages/translation/src/lang/it.json b/packages/translation/src/lang/it.json index bc0e51e3f..b0e0e07fc 100644 --- a/packages/translation/src/lang/it.json +++ b/packages/translation/src/lang/it.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Nuova sezione dinamica", - "remove": "Rimuovi sezione dinamica" + "create": "Nuovo container", + "remove": "Rimuovi container" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Rimuovi sezione dinamica", - "message": "Vuoi davvero rimuovere questa sezione dinamica? Gli elementi verranno spostati nella stessa posizione nella sezione padre." + "title": "Rimuovi container", + "message": "Vuoi davvero rimuovere questo container? Gli elementi verranno spostati nella stessa posizione nella sezione padre." } } }, diff --git a/packages/translation/src/lang/ja.json b/packages/translation/src/lang/ja.json index 88cbb2c29..cbe693178 100644 --- a/packages/translation/src/lang/ja.json +++ b/packages/translation/src/lang/ja.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "新しいダイナミックセクション", - "remove": "ダイナミックセクションを削除する" + "create": "新しいコンテナー", + "remove": "コンテナーを削除する" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "ダイナミックセクションの削除", - "message": "この動的セクションを削除してもよろしいですか?アイテムは親セクションの同じ場所に移動されます。" + "title": "コンテナーの削除", + "message": "このコンテナーを削除してもよろしいですか?アイテムは親セクションの同じ場所に移動されます。" } } }, diff --git a/packages/translation/src/lang/ko.json b/packages/translation/src/lang/ko.json index 9ceefd924..a0ef13b77 100644 --- a/packages/translation/src/lang/ko.json +++ b/packages/translation/src/lang/ko.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "새 동적 섹션", - "remove": "동적 섹션 제거" + "create": "새 컨테이너", + "remove": "컨테이너 제거" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "동적 섹션 제거", - "message": "이 동적 섹션을 제거하시겠습니까? 항목은 상위 섹션의 동일한 위치로 이동합니다." + "title": "컨테이너 제거", + "message": "이 컨테이너를 제거하시겠습니까? 항목은 상위 섹션의 동일한 위치로 이동합니다." } } }, diff --git a/packages/translation/src/lang/lt.json b/packages/translation/src/lang/lt.json index 887719750..ad6d8ea14 100644 --- a/packages/translation/src/lang/lt.json +++ b/packages/translation/src/lang/lt.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Nauja dinaminė sekcija", - "remove": "Pašalinti dinaminę sekciją" + "create": "Naujas konteineris", + "remove": "Pašalinti konteinerį" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Pašalinti dinaminę sekciją", - "message": "Ar tikrai norite pašalinti šią dinaminę sekciją? Elementai bus perkelti į tą pačią vietą pirminėje sekcijoje." + "title": "Pašalinti konteinerį", + "message": "Ar tikrai norite pašalinti šį konteinerį? Elementai bus perkelti į tą pačią vietą pirminėje sekcijoje." } } }, diff --git a/packages/translation/src/lang/lv.json b/packages/translation/src/lang/lv.json index f591202ed..c8ea7bcdf 100644 --- a/packages/translation/src/lang/lv.json +++ b/packages/translation/src/lang/lv.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Jauna dinamiskā sadaļa", - "remove": "Noņemt dinamisko sadaļu" + "create": "Jauns konteiners", + "remove": "Noņemt konteineru" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Noņemt dinamisko sadaļu", - "message": "Vai esat pārliecināts, ka vēlaties noņemt šo dinamisko sadaļu? Vienumi tiks pārvietoti uz to pašu atrašanās vietu vecāku sadaļā." + "title": "Noņemt konteineru", + "message": "Vai esat pārliecināts, ka vēlaties noņemt šo konteineru? Vienumi tiks pārvietoti uz to pašu atrašanās vietu vecāku sadaļā." } } }, diff --git a/packages/translation/src/lang/nl.json b/packages/translation/src/lang/nl.json index 09e179b9b..297c15a4c 100644 --- a/packages/translation/src/lang/nl.json +++ b/packages/translation/src/lang/nl.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Nieuwe dynamische sectie", - "remove": "Dynamische sectie verwijderen" + "create": "Nieuwe container", + "remove": "Container verwijderen" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Dynamische sectie verwijderen", - "message": "Weet je zeker dat je deze dynamische sectie wilt verwijderen? Items worden verplaatst naar dezelfde locatie in de bovenliggende sectie." + "title": "Container verwijderen", + "message": "Weet je zeker dat je deze container wilt verwijderen? Items worden verplaatst naar dezelfde locatie in de bovenliggende sectie." } } }, diff --git a/packages/translation/src/lang/no.json b/packages/translation/src/lang/no.json index 7fb9a827c..f8c428a1a 100644 --- a/packages/translation/src/lang/no.json +++ b/packages/translation/src/lang/no.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Ny dynamisk seksjon", - "remove": "Fjern dynamisk seksjon" + "create": "Ny beholder", + "remove": "Fjern beholder" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Fjern dynamisk seksjon", - "message": "Er du sikker på at du vil fjerne denne dynamiske delen? Elementer vil bli flyttet til samme sted i overordnet seksjon." + "title": "Fjern beholder", + "message": "Er du sikker på at du vil fjerne denne beholderen? Elementer vil bli flyttet til samme sted i overordnet seksjon." } } }, diff --git a/packages/translation/src/lang/pl.json b/packages/translation/src/lang/pl.json index f82360238..fe5200b5f 100644 --- a/packages/translation/src/lang/pl.json +++ b/packages/translation/src/lang/pl.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Nowa sekcja dynamiczna", - "remove": "Usuń sekcję dynamiczną" + "create": "Nowy kontener", + "remove": "Usuń kontener" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Usuń sekcję dynamiczną", - "message": "Czy na pewno chcesz usunąć tę sekcję dynamiczną? Elementy zostaną przeniesione w tej samej lokalizacji w sekcji nadrzędnej." + "title": "Usuń kontener", + "message": "Czy na pewno chcesz usunąć ten kontener? Elementy zostaną przeniesione w tej samej lokalizacji w sekcji nadrzędnej." } } }, diff --git a/packages/translation/src/lang/pt-br.json b/packages/translation/src/lang/pt-br.json index eb0d3d129..e435a3246 100644 --- a/packages/translation/src/lang/pt-br.json +++ b/packages/translation/src/lang/pt-br.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Nova seção dinâmica", - "remove": "Remover seção dinâmica" + "create": "Novo contêiner", + "remove": "Remover contêiner" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Remover seção dinâmica", - "message": "Tem certeza de que deseja remover esta seção dinâmica? Os itens serão movidos para o mesmo local na seção pai." + "title": "Remover contêiner", + "message": "Tem certeza de que deseja remover este contêiner? Os itens serão movidos para o mesmo local na seção pai." } } }, diff --git a/packages/translation/src/lang/pt.json b/packages/translation/src/lang/pt.json index 2a001c02b..81683d178 100644 --- a/packages/translation/src/lang/pt.json +++ b/packages/translation/src/lang/pt.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Nova seção dinâmica", - "remove": "Remover seção dinâmica" + "create": "Novo contêiner", + "remove": "Remover contêiner" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Remover seção dinâmica", - "message": "Tem certeza de que deseja remover esta seção dinâmica? Os itens serão movidos para o mesmo local na seção pai." + "title": "Remover contêiner", + "message": "Tem certeza de que deseja remover este contêiner? Os itens serão movidos para o mesmo local na seção pai." } } }, diff --git a/packages/translation/src/lang/ro.json b/packages/translation/src/lang/ro.json index f10dc9b2c..6f0cea0ae 100644 --- a/packages/translation/src/lang/ro.json +++ b/packages/translation/src/lang/ro.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "", - "remove": "" + "create": "Container nou", + "remove": "Elimină containerul" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "", - "message": "" + "title": "Elimină containerul", + "message": "Sigur doriți să eliminați acest container? Elementele vor fi mutate în aceeași poziție din secțiunea părinte." } } }, diff --git a/packages/translation/src/lang/ru.json b/packages/translation/src/lang/ru.json index 89f1f22d7..78618d979 100644 --- a/packages/translation/src/lang/ru.json +++ b/packages/translation/src/lang/ru.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Новый динамический элемент", - "remove": "Удалить динамический элемент" + "create": "Новый контейнер", + "remove": "Удалить контейнер" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Удалить динамический элемент", - "message": "Вы уверены, что хотите удалить этот динамический элемент? Содержимое будет перемещено в то же место в родительском элементе." + "title": "Удалить контейнер", + "message": "Вы уверены, что хотите удалить этот контейнер? Содержимое будет перемещено в то же место в родительском элементе." } } }, diff --git a/packages/translation/src/lang/sk.json b/packages/translation/src/lang/sk.json index bb10a2e16..b9d916d09 100644 --- a/packages/translation/src/lang/sk.json +++ b/packages/translation/src/lang/sk.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Nová dynamická sekcia", - "remove": "Nová dynamická sekcia" + "create": "Nový kontajner", + "remove": "Odstrániť kontajner" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Nová dynamická sekcia", - "message": "Naozaj chcete odstrániť túto dynamickú sekciu? Položky sa presunú na rovnaké miesto v nadradenej sekcii." + "title": "Odstrániť kontajner", + "message": "Naozaj chcete odstrániť tento kontajner? Položky sa presunú na rovnaké miesto v nadradenej sekcii." } } }, diff --git a/packages/translation/src/lang/sl.json b/packages/translation/src/lang/sl.json index 1207e9550..b591312b5 100644 --- a/packages/translation/src/lang/sl.json +++ b/packages/translation/src/lang/sl.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Nova dinamična vsebina", - "remove": "Odstrani dinamični odsek" + "create": "Nov vsebnik", + "remove": "Odstrani vsebnik" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Odstrani dinamični odsek", - "message": "Ali ste prepričani, da želite odstraniti ta dinamični odsek? Elementi bodo premaknjeni na isto mesto v nadrejenem odseku." + "title": "Odstrani vsebnik", + "message": "Ali ste prepričani, da želite odstraniti ta vsebnik? Elementi bodo premaknjeni na isto mesto v nadrejenem odseku." } } }, diff --git a/packages/translation/src/lang/sv.json b/packages/translation/src/lang/sv.json index 2af879484..b504cc6e7 100644 --- a/packages/translation/src/lang/sv.json +++ b/packages/translation/src/lang/sv.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Ny dynamisk sektion", - "remove": "" + "create": "Ny behållare", + "remove": "Ta bort behållare" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "", - "message": "" + "title": "Ta bort behållare", + "message": "Är du säker på att du vill ta bort den här behållaren? Objekten flyttas till samma plats i den överordnade sektionen." } } }, diff --git a/packages/translation/src/lang/tr.json b/packages/translation/src/lang/tr.json index 10167078c..b014d77a2 100644 --- a/packages/translation/src/lang/tr.json +++ b/packages/translation/src/lang/tr.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Yeni Dinamik Bölüm", - "remove": "Dinamik bölümü kaldır" + "create": "Yeni konteyner", + "remove": "Konteyneri kaldır" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Dinamik bölümü kaldır", - "message": "Bu dinamik bölümü kaldırmak istediğinizden emin misiniz? Öğeler üst bölümdeki aynı konuma taşınacaktır." + "title": "Konteyneri kaldır", + "message": "Bu konteyneri kaldırmak istediğinizden emin misiniz? Öğeler üst bölümdeki aynı konuma taşınacaktır." } } }, diff --git a/packages/translation/src/lang/uk.json b/packages/translation/src/lang/uk.json index d9410c471..6f2733665 100644 --- a/packages/translation/src/lang/uk.json +++ b/packages/translation/src/lang/uk.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Нова динамічна секція", - "remove": "Видалити динамічну секцію" + "create": "Новий контейнер", + "remove": "Видалити контейнер" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Видалити динамічний розділ", - "message": "Ви впевнені, що хочете видалити цей динамічний розділ? Елементи будуть переміщені в те саме місце в батьківському розділі." + "title": "Видалити контейнер", + "message": "Ви впевнені, що хочете видалити цей контейнер? Елементи будуть переміщені в те саме місце в батьківському розділі." } } }, diff --git a/packages/translation/src/lang/vi.json b/packages/translation/src/lang/vi.json index 75b94eee6..827047012 100644 --- a/packages/translation/src/lang/vi.json +++ b/packages/translation/src/lang/vi.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "Phần động mới", - "remove": "Xóa phần động" + "create": "Vùng chứa mới", + "remove": "Xóa vùng chứa" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "Xóa phần động", - "message": "Bạn có chắc chắn muốn xóa phần động này không? Các mục sẽ được chuyển đến cùng vị trí trong phần cha." + "title": "Xóa vùng chứa", + "message": "Bạn có chắc chắn muốn xóa vùng chứa này không? Các mục sẽ được chuyển đến cùng vị trí trong phần cha." } } }, diff --git a/packages/translation/src/lang/zh.json b/packages/translation/src/lang/zh.json index 04e25fbc6..dbac69769 100644 --- a/packages/translation/src/lang/zh.json +++ b/packages/translation/src/lang/zh.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "創建動態區段", - "remove": "移除動態區段" + "create": "新增容器", + "remove": "移除容器" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "移除動態區段", - "message": "確認要移除此動態區段嗎?項目將移動到父區段的相同位置" + "title": "移除容器", + "message": "確認要移除此容器嗎?項目將移動到父區段的相同位置" } } },