Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8985899ff1 | ||
|
|
c9caf69bdb | ||
|
|
9f192c137a |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 618 KiB |
@@ -65,7 +65,7 @@ We have called this the **"ultimate Homarr update"**. Not because work stops, bu
|
||||
|
||||
We threw out the old drag-and-drop system and rebuilt it on [`dnd-kit`](https://dndkit.com/). Moves and eight-direction resizes are transactional: Homarr previews the result, checks collisions and only saves a valid layout. Bad moves roll back instead of wrecking the board. In short: less fighting with your own dashboard.
|
||||
|
||||

|
||||

|
||||
|
||||
### Goodbye Groups and sections, bonjour Containers
|
||||
|
||||
|
||||
@@ -36,11 +36,6 @@ current Base layout.
|
||||
A **Container** groups apps, widgets, and nested Containers. It can have a label, collapse control, app-opening action,
|
||||
border color, and CSS classes.
|
||||
|
||||
Items can occupy the outermost rows and columns of a Container. The drop preview shows the position where the item
|
||||
will remain when released.
|
||||
Dragging an item onto another item of the same size swaps their positions. Displaced items snap directly to their
|
||||
previewed positions without a movement animation and stay there when you release the pointer.
|
||||
|
||||
A **rail** reserves one to three columns at the left or right of a non-Mobile layout. Rails stay fixed while the page
|
||||
scrolls and store their contents separately for each layout.
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { z } from "zod/v4";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
@@ -108,7 +107,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
||||
const initialValuesRef = useRef(buildInitialValues(board));
|
||||
const lastSavedRef = useRef({ pageTitle: board.pageTitle, logoImageUrl: board.logoImageUrl });
|
||||
|
||||
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
|
||||
useUnsavedChangesGuard(form.isDirty());
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { z } from "zod/v4";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
@@ -81,7 +80,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
|
||||
initialValues,
|
||||
});
|
||||
|
||||
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
|
||||
useUnsavedChangesGuard(form.isDirty());
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
|
||||
+1
-2
@@ -27,7 +27,6 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -126,7 +125,7 @@ export const UserGeneralSettingsForm = ({
|
||||
initialValues,
|
||||
});
|
||||
|
||||
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
|
||||
useUnsavedChangesGuard(form.isDirty());
|
||||
|
||||
const weekDays = useMemo(() => dayjs.weekdays(false), []);
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
.board-grid-editor {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
margin: 0 auto;
|
||||
inset: 0 auto auto 0;
|
||||
overflow: visible;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
pointer-events: none;
|
||||
zoom: var(--board-grid-content-scale, 1);
|
||||
}
|
||||
|
||||
.board-grid-entry {
|
||||
@@ -22,7 +20,14 @@
|
||||
width: var(--board-grid-preview-width) !important;
|
||||
height: var(--board-grid-preview-height) !important;
|
||||
translate: var(--board-grid-preview-x) var(--board-grid-preview-y);
|
||||
transition: none;
|
||||
will-change: translate;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-preview="true"]:not([data-dnd-active="true"]) {
|
||||
transition:
|
||||
translate 100ms ease,
|
||||
width 100ms ease,
|
||||
height 100ms ease;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-dnd-active="true"],
|
||||
@@ -104,8 +109,8 @@
|
||||
zoom: var(--board-grid-overlay-content-zoom, 1);
|
||||
}
|
||||
|
||||
/* Displaced entries must snap with the preview too; animating their offsets can trail a new swap target. */
|
||||
body[data-board-grid-interacting] .board-grid-entry,
|
||||
body[data-board-grid-interacting] .board-grid-entry[data-dnd-active="true"],
|
||||
body[data-board-grid-interacting] .board-grid-entry[data-dnd-drag-source="true"],
|
||||
body[data-board-grid-interacting] .board-grid-placeholder {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -415,11 +415,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
sectionId: grid.id,
|
||||
placements: grid.placements,
|
||||
}));
|
||||
const hasChanges = commitSectionGrids(snapshots);
|
||||
if (!hasChanges) {
|
||||
clearInteraction();
|
||||
return;
|
||||
}
|
||||
commitSectionGrids(snapshots);
|
||||
|
||||
const placement = state.grids
|
||||
.flatMap((grid) => grid.placements)
|
||||
@@ -433,9 +429,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
)} ${placement.h}`,
|
||||
);
|
||||
}
|
||||
// Cache subscribers receive the committed board asynchronously. Keep the preview until
|
||||
// the board-change layout effect clears it, so displaced items never flash at their old positions.
|
||||
activeRef.current = null;
|
||||
clearInteraction();
|
||||
},
|
||||
[announce, clearInteraction, commitSectionGrids, currentLayoutId, t],
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
|
||||
.editorGrid {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
}
|
||||
|
||||
.editorPortalHost {
|
||||
@@ -62,11 +63,6 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Containers provide their own inner item gap, so their card must keep the full grid aspect ratio. */
|
||||
.staticItem[data-type="section"] > .contentMount {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.staticItem[data-type="item"] > .contentMount {
|
||||
inset: calc(10px * var(--board-canvas-inverse-scale, 1));
|
||||
}
|
||||
|
||||
@@ -143,15 +143,40 @@ export const SectionGrid = ({
|
||||
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
|
||||
const viewportRowCount =
|
||||
viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount);
|
||||
// A container's own visible card is inset from its allocated board cell by the board's
|
||||
// standard per-item gap (see the base, non-container `.staticItem[data-type="item"] >
|
||||
// .contentMount` rule in section-grid.module.css - a container is placed as an "item" like
|
||||
// any widget, so it gets that same gap). A widget's content just fills whatever size its
|
||||
// card ends up being, but this SectionGrid instead computes its own fixed pixel size from
|
||||
// the same column/row counts used to allocate the *outer*, uninset cell - so without
|
||||
// subtracting that gap back out here, a container's inner grid renders larger than its own
|
||||
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
|
||||
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
|
||||
// The collapsible label keeps a fixed physical height while the grid uses logical canvas pixels.
|
||||
// Convert that height so the label reserves the same space at every board scale.
|
||||
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
|
||||
// A collapsible container's toggle bar (see the `containerToggle` Button in
|
||||
// container-section.tsx) is an absolutely positioned overlay sitting on top of this grid.
|
||||
// Its height comes from a Mantine size prop, which - like spacing/font-size - is compensated
|
||||
// by --mantine-scale to render at a constant physical size regardless of board zoom (see
|
||||
// scaled-board-canvas.module.css). That makes it behave like the outerCardInset gap above,
|
||||
// not like the grid's own un-compensated logical-pixel math, so it needs the same
|
||||
// effectiveCanvasScale conversion - a plain, unconverted subtraction would under-reserve on
|
||||
// a zoomed-out board and leave the header overlapping the content. Without reserving it at
|
||||
// all, the header bar covers the first row of the container's content instead of sitting
|
||||
// above it. Only the vertical axis is affected - the toggle spans the full width already.
|
||||
const collapsibleHeaderInset =
|
||||
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0;
|
||||
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
|
||||
const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - outerCardInset - collapsibleHeaderInset);
|
||||
// Items are positioned in a coordinate space sized to the *un-inset* column/row count
|
||||
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
|
||||
// deliberately smaller by outerCardInset to match the container's actual visible card size.
|
||||
// Left alone, that mismatch means the items - not just the grid's own box - spill past the
|
||||
// card's right/bottom edge by exactly that inset. Scale the grid's content down by the same
|
||||
// ratio the board's own canvas uses for its whole-board zoom (see ScaledBoardCanvas), just one
|
||||
// level deeper, so it fits the actual card instead of clipping. --board-canvas-ui-scale is
|
||||
// corrected to compensate so icon/text sizing inside the container isn't affected.
|
||||
const fullGridWidth = getLogicalGridSize(columnCount);
|
||||
const fullGridHeight = getLogicalGridSize(rowCount);
|
||||
const logicalWidth = fullGridWidth;
|
||||
const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - collapsibleHeaderInset);
|
||||
// For a scrollable container, rowCount covers *all* content rows, not just the visible card -
|
||||
// fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content
|
||||
// grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible
|
||||
@@ -159,9 +184,16 @@ export const SectionGrid = ({
|
||||
// describe the real visible card size in both cases (they equal the non-scrollable values when
|
||||
// rowCount and viewportRowCount are the same), so use those instead.
|
||||
const fullViewportHeight = getLogicalGridSize(viewportRowCount);
|
||||
// A collapsible container reserves a fixed-height label above the square-cell grid. Keep the
|
||||
// nested content uniformly scaled within the remaining height so labels and icons retain their
|
||||
// proportions; ordinary containers use the original unscaled, edge-aligned grid geometry.
|
||||
// A uniform zoom is required (not a non-uniform transform scale(x, y)) - --board-canvas-ui-scale
|
||||
// is a single scalar that every icon/text/custom-CSS size compensation in the app multiplies
|
||||
// by, so a non-uniform stretch can never be captured by it correctly. That means one axis can
|
||||
// be left with a small amount of unfilled slack when width/height need different ratios to
|
||||
// exactly fit - centered below so it's even on both sides rather than left-aligned.
|
||||
// logicalWidth/viewportHeight can go non-positive for a narrow (e.g. single-column) container
|
||||
// on a heavily zoomed-out board, where outerCardInset (which grows as canvasScale shrinks) can
|
||||
// exceed the container's own un-inset size. Floor the scale well above 0 so it can never reach
|
||||
// zero or negative - combinedUiScale divides by this value, and this value is also used as
|
||||
// zoom directly, both of which break (Infinity/NaN, or invalid/collapsed content) otherwise.
|
||||
const containerContentScale =
|
||||
section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0
|
||||
? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1))
|
||||
@@ -270,7 +302,6 @@ export const SectionGrid = ({
|
||||
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
|
||||
marginTop: collapsibleHeaderInset || undefined,
|
||||
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
|
||||
"--board-grid-content-scale": containerContentScale,
|
||||
} as CSSProperties
|
||||
}
|
||||
data-section-id={section.id}
|
||||
|
||||
@@ -33,8 +33,8 @@ export const useGridLayoutActions = () => {
|
||||
placements.map((placement) => [`${layoutId}:${placement.id}`, { layoutId, sectionId, placement }] as const),
|
||||
),
|
||||
);
|
||||
let hasChanges = false;
|
||||
updateBoard((previous) => {
|
||||
let hasChanges = false;
|
||||
const items = previous.items.map((item) => {
|
||||
const layouts = item.layouts.map((layout) => {
|
||||
const resolved = byId.get(`${layout.layoutId}:${item.id}`);
|
||||
@@ -97,7 +97,6 @@ export const useGridLayoutActions = () => {
|
||||
|
||||
return hasChanges ? { ...previous, items, sections } : previous;
|
||||
});
|
||||
return hasChanges;
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
@@ -21,9 +21,6 @@
|
||||
--border-color: rgb(
|
||||
from var(--container-border-color, var(--container-default-border-color)) r g b / var(--opacity, 1)
|
||||
);
|
||||
/* Keep the border visual without letting it shrink the square-cell grid's content box. */
|
||||
border-width: 0;
|
||||
box-shadow: inset 0 0 0 calc(1px * var(--board-canvas-inverse-scale, 1)) var(--border-color);
|
||||
}
|
||||
|
||||
.itemCard:global(.iframe-wrapper) {
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
interface UnsavedChangesGuardOptions {
|
||||
isDirty(): boolean;
|
||||
confirmNavigation(href: string): void;
|
||||
guardBeforeUnload?: boolean;
|
||||
}
|
||||
|
||||
export function registerUnsavedChangesGuard({
|
||||
isDirty,
|
||||
confirmNavigation,
|
||||
guardBeforeUnload = true,
|
||||
}: UnsavedChangesGuardOptions) {
|
||||
export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) {
|
||||
let currentHref = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!isDirty() || event.defaultPrevented || event.button !== 0) return;
|
||||
@@ -22,7 +17,6 @@ export function registerUnsavedChangesGuard({
|
||||
const current = new URL(window.location.href);
|
||||
if (destination.origin !== current.origin) return;
|
||||
if (destination.pathname === current.pathname && destination.search === current.search) {
|
||||
currentHref = `${destination.pathname}${destination.search}${destination.hash}`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,11 +48,11 @@ export function registerUnsavedChangesGuard({
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleClick, true);
|
||||
if (guardBeforeUnload) window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClick, true);
|
||||
if (guardBeforeUnload) window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
};
|
||||
}
|
||||
@@ -7,11 +7,7 @@ import { useConfirmModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { registerUnsavedChangesGuard } from "./unsaved-changes-guard";
|
||||
|
||||
interface UnsavedChangesGuardOptions {
|
||||
guardBeforeUnload?: boolean;
|
||||
}
|
||||
|
||||
export function useUnsavedChangesGuard(isDirty: boolean, options: UnsavedChangesGuardOptions = {}) {
|
||||
export function useUnsavedChangesGuard(isDirty: boolean) {
|
||||
const tConfirmLeave = useI18n("board.action.edit.confirmLeave");
|
||||
const tCommon = useI18n("common");
|
||||
const router = useRouter();
|
||||
@@ -43,8 +39,7 @@ export function useUnsavedChangesGuard(isDirty: boolean, options: UnsavedChanges
|
||||
registerUnsavedChangesGuard({
|
||||
isDirty: () => dirtyRef.current,
|
||||
confirmNavigation: (href) => confirmNavigationRef.current(href),
|
||||
guardBeforeUnload: options.guardBeforeUnload,
|
||||
}),
|
||||
[options.guardBeforeUnload],
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -89,7 +89,7 @@ describe("Onboarding", () => {
|
||||
|
||||
try {
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/init`);
|
||||
await page.getByRole("heading", { name: "Welcome home." }).waitFor();
|
||||
await page.getByRole("heading", { name: "Make home feel organized." }).waitFor();
|
||||
const start = page.getByRole("button", { name: "Get started" });
|
||||
await start.focus();
|
||||
await start.press("Enter");
|
||||
@@ -356,7 +356,7 @@ describe("Onboarding", () => {
|
||||
);
|
||||
|
||||
await loginAsync({ page, baseUrl, credentials, destination: "/init" });
|
||||
await page.getByRole("heading", { name: "Welcome home." }).waitFor();
|
||||
await page.getByRole("heading", { name: "Make home feel organized." }).waitFor();
|
||||
await page.getByRole("button", { name: "Get started" }).click();
|
||||
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
|
||||
expect(await page.getByRole("heading", { name: "Create your administrator" }).count()).toBe(0);
|
||||
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { IconBulb, IconListCheck, IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
import { IconListCheck, IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { FloatingTip, selectGridCols, SelectableCard } from "@homarr/ui";
|
||||
import { selectGridCols, SelectableCard } from "@homarr/ui";
|
||||
|
||||
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||
|
||||
@@ -106,17 +106,6 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<FloatingTip
|
||||
opened={multiSelectAvailable}
|
||||
showDelay={2_000}
|
||||
dismissAfter={3_000}
|
||||
transitionDuration={200}
|
||||
closable={false}
|
||||
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
|
||||
>
|
||||
{t("tips.multiSelectApps")}
|
||||
</FloatingTip>
|
||||
|
||||
{/* Top Search Input */}
|
||||
<Stack gap={6}>
|
||||
<Input
|
||||
|
||||
@@ -27,11 +27,11 @@
|
||||
"review": "Review"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Welcome home.",
|
||||
"title": "Make home feel organized.",
|
||||
"description": "Welcome to Homarr v{version}. We’ll guide you through the essentials and help you build your first board.",
|
||||
"start": "Get started",
|
||||
"restore": "Restore backup",
|
||||
"restoreTitle": "Restore from backup",
|
||||
"restoreTitle": "Restore this Homarr",
|
||||
"restoreDescription": "A full SQLite backup restores boards, people, apps, integrations, and settings. Homarr validates it before replacing anything.",
|
||||
"errorTitle": "Setup could not start",
|
||||
"errorDescription": "Your setup choices are still here. Try again, or reload the page if this continues."
|
||||
|
||||
Reference in new issue
Block a user