fix: address board grid review
This commit is contained in:
1 parent
cddae6719e
commit
28ac23e66b
51 files changed
+647
-278
No files matched your search
@@ -1,9 +1,5 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Users
|
||||
|
||||
Homarr is used by self-hosters and administrators who assemble dashboards for services running across home labs and private infrastructure. They need dense, reliable controls and status at a glance, with enough guidance to configure advanced features without hiding how they work.
|
||||
|
||||
+72
-4
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string>();
|
||||
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<string, SectionGridPlacement>();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<typeof containerSectionOptionsSchema>;
|
||||
newOptions: ContainerSectionOptions;
|
||||
}
|
||||
|
||||
export const useContainerActions = () => {
|
||||
|
||||
@@ -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<SectionGridPlacement>;
|
||||
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, {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export const getGridDepth = (gridId: string, parentGridById: ReadonlyMap<string, string | null>) => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<GridInteraction | null>(null);
|
||||
const [overlayZoom, setOverlayZoom] = useState(1);
|
||||
const parentGridById = useMemo(
|
||||
() =>
|
||||
new Map<string, string | null>(
|
||||
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<HTMLDivElement>(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<RegisteredGrid>,
|
||||
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<string, unknown> }): 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<HTMLElement>(`[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<SectionGridPlacement>) =>
|
||||
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)),
|
||||
);
|
||||
@@ -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<GridPortalHostContextValue | null>(n
|
||||
* content.
|
||||
*/
|
||||
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
const board = useRequiredBoard();
|
||||
const integrations = useIntegrations();
|
||||
const containersRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const [containers, setContainers] = useState<Map<string, HTMLElement>>(() => 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<GridPortalHostContextValue>(
|
||||
() => ({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<HTMLDivElement>("[data-editor-grid-entry]");
|
||||
if (!entry) throw new Error("Expected the fixed grid item to render an editor entry");
|
||||
|
||||
@@ -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<string, string | null>([
|
||||
["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<string, string | null>([
|
||||
["first", "second"],
|
||||
["second", "first"],
|
||||
]);
|
||||
|
||||
expect(getGridDepth("first", parents)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HTMLElement>("[data-container-count]")?.dataset.containerCount;
|
||||
@@ -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,
|
||||
|
||||
@@ -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}')`);
|
||||
}
|
||||
|
||||
|
||||
@@ -925,24 +925,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<typeof schema.sectionLayouts> => ({
|
||||
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<typeof schema.sectionLayouts> => ({
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1125,27 +1125,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<typeof sectionLayouts> => ({
|
||||
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<typeof sectionLayouts> => ({
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nova secció dinàmica",
|
||||
"remove": "Elimina la secció dinàmica"
|
||||
"create": "Nou contenidor",
|
||||
"remove": "Elimina el contenidor"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "新建动态部分",
|
||||
"remove": "删除动态部分"
|
||||
"create": "新建容器",
|
||||
"remove": "删除容器"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "删除动态部分",
|
||||
"message": "你确定你要删除这个动态部分?项目将被移动到父节的相同位置。"
|
||||
"title": "删除容器",
|
||||
"message": "你确定要删除这个容器吗?项目将被移动到父级区域的相同位置。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nová dynamická sekce",
|
||||
"remove": "Odstranit dynamickou sekci"
|
||||
"create": "Nový kontejner",
|
||||
"remove": "Odstranit kontejner"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Ny dynamisk sektion",
|
||||
"remove": "Fjern dynamisk sektion"
|
||||
"create": "Ny container",
|
||||
"remove": "Fjern container"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Neuer dynamischer Abschnitt",
|
||||
"remove": "Dynamischen Abschnitt entfernen"
|
||||
"create": "Neuer Container",
|
||||
"remove": "Container entfernen"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1400,8 +1400,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Neuer dynamischer Abschnitt",
|
||||
"remove": "Dynamischen Abschnitt entfernen"
|
||||
"create": "Neuer Container",
|
||||
"remove": "Container entfernen"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1415,8 +1415,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "",
|
||||
"remove": ""
|
||||
"create": "Νέο κοντέινερ",
|
||||
"remove": "Κατάργηση κοντέινερ"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Κατάργηση κοντέινερ",
|
||||
"message": "Είστε βέβαιοι ότι θέλετε να καταργήσετε αυτό το κοντέινερ; Τα στοιχεία θα μετακινηθούν στην ίδια θέση στη γονική ενότητα."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nueva sección dinámica",
|
||||
"remove": "Eliminar sección dinámica"
|
||||
"create": "Nuevo contenedor",
|
||||
"remove": "Eliminar contenedor"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Uus dünaamiline sektsioon",
|
||||
"remove": "Eemalda dünaamiline sektsioon"
|
||||
"create": "Uus konteiner",
|
||||
"remove": "Eemalda konteiner"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Uusi dynaaminen osio",
|
||||
"remove": ""
|
||||
"create": "Uusi kontti",
|
||||
"remove": "Poista kontti"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1399,8 +1399,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nouvelle section dynamique",
|
||||
"remove": "Supprimer la section dynamique"
|
||||
"create": "Nouveau conteneur",
|
||||
"remove": "Supprimer le conteneur"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1414,8 +1414,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "מדור דינמי חדש",
|
||||
"remove": "הסר קטע דינמי"
|
||||
"create": "מיכל חדש",
|
||||
"remove": "הסר מיכל"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "הסר קטע דינמי",
|
||||
"message": "האם אתה בטוח שברצונך להסיר את הקטע הדינמי הזה? פריטים יועברו לאותו מיקום במדור האב."
|
||||
"title": "הסר מיכל",
|
||||
"message": "האם אתה בטוח שברצונך להסיר את המיכל הזה? פריטים יועברו לאותו מיקום במדור האב."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nova dinamička sekcija",
|
||||
"remove": "Ukloni dinamičku sekciju"
|
||||
"create": "Novi kontejner",
|
||||
"remove": "Ukloni kontejner"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,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": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1400,8 +1400,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nuova sezione dinamica",
|
||||
"remove": "Rimuovi sezione dinamica"
|
||||
"create": "Nuovo container",
|
||||
"remove": "Rimuovi container"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1415,8 +1415,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "新しいダイナミックセクション",
|
||||
"remove": "ダイナミックセクションを削除する"
|
||||
"create": "新しいコンテナー",
|
||||
"remove": "コンテナーを削除する"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "ダイナミックセクションの削除",
|
||||
"message": "この動的セクションを削除してもよろしいですか?アイテムは親セクションの同じ場所に移動されます。"
|
||||
"title": "コンテナーの削除",
|
||||
"message": "このコンテナーを削除してもよろしいですか?アイテムは親セクションの同じ場所に移動されます。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1400,8 +1400,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "새 동적 섹션",
|
||||
"remove": "동적 섹션 제거"
|
||||
"create": "새 컨테이너",
|
||||
"remove": "컨테이너 제거"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1415,8 +1415,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "동적 섹션 제거",
|
||||
"message": "이 동적 섹션을 제거하시겠습니까? 항목은 상위 섹션의 동일한 위치로 이동합니다."
|
||||
"title": "컨테이너 제거",
|
||||
"message": "이 컨테이너를 제거하시겠습니까? 항목은 상위 섹션의 동일한 위치로 이동합니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nauja dinaminė sekcija",
|
||||
"remove": "Pašalinti dinaminę sekciją"
|
||||
"create": "Naujas konteineris",
|
||||
"remove": "Pašalinti konteinerį"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1356,8 +1356,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Jauna dinamiskā sadaļa",
|
||||
"remove": "Noņemt dinamisko sadaļu"
|
||||
"create": "Jauns konteiners",
|
||||
"remove": "Noņemt konteineru"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1371,8 +1371,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ļā."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nieuwe dynamische sectie",
|
||||
"remove": "Dynamische sectie verwijderen"
|
||||
"create": "Nieuwe container",
|
||||
"remove": "Container verwijderen"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Ny dynamisk seksjon",
|
||||
"remove": "Fjern dynamisk seksjon"
|
||||
"create": "Ny beholder",
|
||||
"remove": "Fjern beholder"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1400,8 +1400,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nowa sekcja dynamiczna",
|
||||
"remove": "Usuń sekcję dynamiczną"
|
||||
"create": "Nowy kontener",
|
||||
"remove": "Usuń kontener"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1415,8 +1415,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1398,8 +1398,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": {
|
||||
@@ -1413,8 +1413,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,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": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "",
|
||||
"remove": ""
|
||||
"create": "Container nou",
|
||||
"remove": "Elimină containerul"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Новый динамический элемент",
|
||||
"remove": "Удалить динамический элемент"
|
||||
"create": "Новый контейнер",
|
||||
"remove": "Удалить контейнер"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "Удалить динамический элемент",
|
||||
"message": "Вы уверены, что хотите удалить этот динамический элемент? Содержимое будет перемещено в то же место в родительском элементе."
|
||||
"title": "Удалить контейнер",
|
||||
"message": "Вы уверены, что хотите удалить этот контейнер? Содержимое будет перемещено в то же место в родительском элементе."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nová dynamická sekcia",
|
||||
"remove": "Nová dynamická sekcia"
|
||||
"create": "Nový kontajner",
|
||||
"remove": "Odstrániť kontajner"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nova dinamična vsebina",
|
||||
"remove": "Odstrani dinamični odsek"
|
||||
"create": "Nov vsebnik",
|
||||
"remove": "Odstrani vsebnik"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Ny dynamisk sektion",
|
||||
"remove": ""
|
||||
"create": "Ny behållare",
|
||||
"remove": "Ta bort behållare"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,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": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1356,8 +1356,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Нова динамічна секція",
|
||||
"remove": "Видалити динамічну секцію"
|
||||
"create": "Новий контейнер",
|
||||
"remove": "Видалити контейнер"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1371,8 +1371,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "Видалити динамічний розділ",
|
||||
"message": "Ви впевнені, що хочете видалити цей динамічний розділ? Елементи будуть переміщені в те саме місце в батьківському розділі."
|
||||
"title": "Видалити контейнер",
|
||||
"message": "Ви впевнені, що хочете видалити цей контейнер? Елементи будуть переміщені в те саме місце в батьківському розділі."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,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": {
|
||||
@@ -1361,8 +1361,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "創建動態區段",
|
||||
"remove": "移除動態區段"
|
||||
"create": "新增容器",
|
||||
"remove": "移除容器"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1361,8 +1361,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "移除動態區段",
|
||||
"message": "確認要移除此動態區段嗎?項目將移動到父區段的相同位置"
|
||||
"title": "移除容器",
|
||||
"message": "確認要移除此容器嗎?項目將移動到父區段的相同位置"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user