From 2dbeb792a791f316837f80cc3d0b23154f1cf0d7 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 24 Aug 2026 18:23:50 +0200 Subject: [PATCH] fix: refine header layout editor --- .../_components/header-composer.module.css | 184 +++++---------- .../general/_components/header-composer.tsx | 220 ++++++++---------- packages/translation/src/lang/en.json | 2 +- 3 files changed, 160 insertions(+), 246 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.module.css b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.module.css index 3e87a1f69..31819934f 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.module.css +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.module.css @@ -1,13 +1,7 @@ .composer { width: 100%; overflow: hidden; - background: - radial-gradient( - ellipse 70% 42% at 50% 0%, - color-mix(in srgb, var(--mantine-primary-color-light) 32%, transparent), - transparent 72% - ), - var(--mantine-color-body); + background: var(--mantine-color-body); } .heading, @@ -22,7 +16,7 @@ .stage { display: grid; place-items: center; - min-height: 20rem; + min-height: 21rem; padding: var(--mantine-spacing-md); background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8)); background-image: radial-gradient(var(--mantine-color-default-border) 0.75px, transparent 0.75px); @@ -31,8 +25,8 @@ .device { position: relative; - width: 100%; - min-height: 15rem; + width: min(100%, 31.2rem); + min-height: 18rem; overflow: hidden; background: var(--mantine-color-body); box-shadow: var(--mantine-shadow-lg); @@ -41,107 +35,89 @@ min-height 180ms ease; } -.stage[data-size="mobile"] .device { - width: min(100%, 24rem); - min-height: 18rem; -} - .previewHeader { position: relative; z-index: 1; - height: 5rem; + min-height: 5rem; border-bottom: 1px solid var(--mantine-color-default-border); background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent); direction: ltr; } -.stripScroller { - width: 100%; - height: 100%; - overflow-x: auto; - overflow-y: hidden; - scrollbar-color: var(--mantine-color-default-border) transparent; - scrollbar-width: thin; -} - -.sortableStrip { - position: relative; - display: flex; - align-items: stretch; - gap: 0.25rem; - width: max-content; - min-width: 100%; - height: 100%; - padding: 0.35rem var(--mantine-spacing-sm) 0.45rem; - white-space: nowrap; -} - -.zoneOriginLabel { - position: absolute; - inset-block-start: 0.85rem; - inset-inline-start: var(--mantine-spacing-sm); - color: var(--mantine-color-dimmed); - font-size: 0.55rem; - font-weight: 700; - line-height: 1; - letter-spacing: 0.06em; - text-transform: uppercase; - user-select: none; -} - -.zoneDelimiter { +.zoneTable { display: grid; - grid-template-rows: 1.2rem 1.75rem; - place-items: center; - gap: 0.2rem; - width: 3.5rem; - height: 100%; - align-content: center; - padding-block: 0.25rem 0.4rem; - flex: 0 0 auto; - user-select: none; + grid-template-columns: repeat(3, minmax(0, 1fr)); + width: 100%; + min-height: 5rem; +} + +.zoneColumn { + min-width: 0; + border-inline-end: 1px solid var(--mantine-color-default-border); transition: background-color 120ms ease; } -.zoneLabel { +.zoneColumn:last-child { + border-inline-end: 0; +} + +.zoneColumn[data-drop-target="true"] { + background: var(--mantine-primary-color-light); +} + +.zoneColumn[data-drop-target="true"] .zoneItems::after { + content: ""; + width: 2.25rem; + height: 2.25rem; + border: 1px dashed var(--mantine-primary-color-filled); + border-radius: var(--mantine-radius-md); + background: color-mix(in srgb, var(--mantine-primary-color-light) 65%, transparent); +} + +.zoneColumnHeader { + display: grid; + place-items: center; + height: 1.75rem; + margin: 0; color: var(--mantine-color-dimmed); + border-bottom: 1px solid var(--mantine-color-default-border); font-size: 0.55rem; font-weight: 700; line-height: 1; letter-spacing: 0.06em; text-transform: uppercase; - transition: color 120ms ease; + user-select: none; } -.zoneLine { - height: 1.5rem; - border-color: var(--mantine-color-default-border); - transition: - height 120ms ease, - border-color 120ms ease; -} - -.zoneDelimiter[data-drop-target="true"] { - background: color-mix(in srgb, var(--mantine-primary-color-light) 48%, transparent); -} - -.zoneDelimiter[data-drop-target="true"] .zoneLabel { - color: var(--mantine-primary-color-filled); -} - -.zoneDelimiter[data-drop-target="true"] .zoneLine { - height: 1.75rem; - border-color: var(--mantine-primary-color-filled); +.zoneItems { + display: flex; + align-content: flex-start; + align-items: flex-start; + flex-wrap: wrap; + gap: 0.25rem; + min-height: 3.25rem; + padding: 0.5rem; } .sortableItem { + position: relative; display: flex; - align-items: flex-end; - height: 100%; - padding-block: 1.55rem 0.125rem; + align-items: center; flex: 0 0 auto; } +.sortableItem[data-drop-target="true"]::before { + content: ""; + position: absolute; + inset-block: 0.2rem; + inset-inline-start: -0.2rem; + z-index: 1; + width: 2px; + border-radius: var(--mantine-radius-xl); + background: var(--mantine-primary-color-filled); + box-shadow: 0 0 0 2px var(--mantine-primary-color-light); +} + .sortableItem[data-dragging="true"] { opacity: 0.2; } @@ -216,28 +192,6 @@ text-overflow: ellipsis; } -.mobileBurger { - display: none; - width: 2.25rem; - height: 2.25rem; - gap: 3px; - place-content: center; - align-self: flex-end; - margin: 0 0.125rem 0.125rem 0; - flex: 0 0 auto; -} - -.mobileBurger span { - display: block; - width: 1rem; - height: 1px; - background: var(--mantine-color-text); -} - -.stage[data-size="mobile"] .mobileBurger { - display: grid; -} - .hiddenPreview { position: absolute; inset: 0 0 auto; @@ -252,7 +206,7 @@ } .dashboardPreview { - height: 11.25rem; + height: 13rem; overflow: hidden; opacity: 0.86; } @@ -262,18 +216,6 @@ aspect-ratio: auto; } -.stage[data-size="mobile"] .sortableStrip { - padding-inline: var(--mantine-spacing-xs); -} - -.stage[data-size="mobile"] .zoneLabel { - font-size: 0.5rem; -} - -.stage[data-size="mobile"] .dashboardPreview { - height: 14.25rem; -} - .tray { border-top: 1px solid var(--mantine-color-default-border); } @@ -296,9 +238,7 @@ @media (prefers-reduced-motion: reduce) { .device, - .zoneDelimiter, - .zoneLabel, - .zoneLine, + .zoneColumn, .itemButton { transition: none; } diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx index ff9dd1da2..38dd43548 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx @@ -1,18 +1,17 @@ "use client"; -import { useMemo, useState } from "react"; -import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"; +import { type ReactNode, useMemo, useState } from "react"; import { PointerActivationConstraints } from "@dnd-kit/dom"; +import { SortableKeyboardPlugin } from "@dnd-kit/dom/sortable"; import type { DragEndEvent } from "@dnd-kit/react"; -import { DragDropProvider, DragOverlay, KeyboardSensor, PointerSensor } from "@dnd-kit/react"; -import { isSortable, useSortable } from "@dnd-kit/react/sortable"; +import { DragDropProvider, DragOverlay, KeyboardSensor, PointerSensor, useDroppable } from "@dnd-kit/react"; +import { useSortable } from "@dnd-kit/react/sortable"; import { ActionIcon, Avatar, Box, Button, Card, - Divider, Group, Menu, Paper, @@ -71,11 +70,19 @@ interface HeaderComposerProps { mobileHomeBoardId: string | null; } -type PreviewSize = "desktop" | "mobile"; +interface HeaderItemDragData { + kind: "item"; + itemKey: string; + zone: HeaderZoneId; + index: number; +} -type HeaderStripEntry = - | { id: string; kind: "divider"; zone: HeaderZoneId } - | { id: string; item: HeaderItem; kind: "item" }; +interface HeaderZoneDropData { + kind: "zone"; + zone: HeaderZoneId; +} + +type HeaderDropData = HeaderItemDragData | HeaderZoneDropData; const headerSortableSensors = [ PointerSensor.configure({ @@ -83,17 +90,16 @@ const headerSortableSensors = [ }), KeyboardSensor, ]; -const headerSortableModifiers = [RestrictToHorizontalAxis]; const headerSortableGroup = "header-composer"; +const headerSortablePlugins = [SortableKeyboardPlugin]; +const headerGuideZones = ["left", "center", "right"] as const; -export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHomeBoardId }: HeaderComposerProps) => { - const [previewSize, setPreviewSize] = useState("desktop"); +export const HeaderComposer = ({ value, onChange, boards, homeBoardId }: HeaderComposerProps) => { const [draggedItemKey, setDraggedItemKey] = useState(null); const [selectedItemKey, setSelectedItemKey] = useState(null); const t = useI18n("management.page.user.setting.general.header"); const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]); const activeItems = getHeaderItems(value.zones); - const stripEntries = createHeaderStripEntries(value.zones); const activeItemKeySet = new Set(activeItems.map(getHeaderItemKey)); const inactiveBuiltinIds = headerBuiltinItemIds.filter((itemId) => { const item = createBuiltinHeaderItem(itemId); @@ -104,9 +110,14 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom ); const draggedItem = findHeaderItem(activeItems, draggedItemKey); const selectedItem = findHeaderItem(activeItems, selectedItemKey); - let previewBoardId = homeBoardId; - if (previewSize === "mobile") previewBoardId = mobileHomeBoardId ?? homeBoardId; - const previewBoard = boardsById.get(previewBoardId ?? "") ?? boards.at(0); + const previewBoard = boardsById.get(homeBoardId ?? "") ?? boards.at(0); + + const toggleSelectedItem = (itemKey: string) => { + setSelectedItemKey((currentItemKey) => { + if (currentItemKey === itemKey) return null; + return itemKey; + }); + }; const itemLabel = (item: HeaderItem) => { if (item.type === "board") return boardsById.get(item.boardId)?.name ?? t("items.boardUnavailable"); @@ -138,11 +149,22 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom const handleDragEnd = ({ canceled, operation }: DragEndEvent) => { setDraggedItemKey(null); const source = operation.source; - if (canceled || !isSortable(source)) return; - if (source.initialIndex === source.index) return; + const target = operation.target; + if (canceled || !source || !target) return; - const nextEntries = moveHeaderStripEntry(stripEntries, source.initialIndex, source.index); - updateZones(createHeaderZonesFromStripEntries(nextEntries)); + const sourceData = source.data as HeaderItemDragData; + const targetData = target.data as HeaderDropData; + if (sourceData.kind !== "item") return; + + let targetIndex = value.zones[targetData.zone].length; + if (targetData.kind === "item") { + if (targetData.itemKey === sourceData.itemKey) return; + targetIndex = targetData.index; + } + + const nextZones = moveHeaderItem(value.zones, sourceData.itemKey, targetData.zone, targetIndex); + if (nextZones === value.zones) return; + updateZones(nextZones); }; return ( @@ -163,15 +185,6 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom - setPreviewSize(next as PreviewSize)} - data={[ - { value: "desktop", label: t("device.desktop") }, - { value: "mobile", label: t("device.mobile") }, - ]} - /> - + {value.visible ? (
-
-
- - {t("zones.left")} - - - {stripEntries.map((entry, index) => { - if (entry.kind === "divider") { - return ( - - ); - } - - const item = entry.item; - return ( +
setSelectedItemKey(null)}> + {headerGuideZones.map((zone) => ( + + {value.zones[zone].map((item, index) => ( - ); - })} -
+ ))} + + ))}
@@ -237,7 +234,7 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom ; - index: number; +interface HeaderZoneColumnProps { + zone: HeaderZoneId; label: string; + children: ReactNode; } -const SortableZoneDivider = ({ entry, index, label }: SortableZoneDividerProps) => { - const { isDropTarget, ref } = useSortable({ - id: entry.id, - index, - group: headerSortableGroup, - data: { kind: entry.kind, zone: entry.zone }, - disabled: { draggable: true }, +const HeaderZoneColumn = ({ zone, label, children }: HeaderZoneColumnProps) => { + const { isDropTarget, ref } = useDroppable({ + id: `header-zone:${zone}`, + data: { kind: "zone", zone } satisfies HeaderZoneDropData, }); return ( -
- +
+ {label} - -
+
{children}
+ ); }; -const MobileBurger = () => ( -
- - - -
-); - interface SortableHeaderItemProps { item: HeaderItem; + zone: HeaderZoneId; index: number; label: string; - previewSize: PreviewSize; searchDisplay: HeaderPreferences["searchDisplay"]; boardLogo: string | null; configureLabel: string; @@ -430,9 +411,9 @@ interface SortableHeaderItemProps { const SortableHeaderItem = ({ item, + zone, index, label, - previewSize, searchDisplay, boardLogo, configureLabel, @@ -443,10 +424,10 @@ const SortableHeaderItem = ({ const { handleRef, isDragging, isDropTarget, ref } = useSortable({ id: itemKey, index, - group: headerSortableGroup, - data: { kind: "item", item }, + group: `${headerSortableGroup}:${zone}`, + data: { kind: "item", itemKey, zone, index } satisfies HeaderItemDragData, sensors: headerSortableSensors, - modifiers: headerSortableModifiers, + plugins: headerSortablePlugins, }); return ( @@ -463,19 +444,19 @@ const SortableHeaderItem = ({ type="button" className={classes.itemButton} data-kind={item.type === "builtin" ? item.id : "board"} - data-compact={previewSize === "mobile" || undefined} data-wide={ - item.type === "builtin" && item.id === "search" && searchDisplay === "input" && previewSize === "desktop" - ? true - : undefined + (item.type === "builtin" && item.id === "search" && searchDisplay === "input") || undefined } aria-label={configureLabel} - onClick={() => onSelect(itemKey)} + onClick={(event) => { + event.stopPropagation(); + onSelect(itemKey); + }} > @@ -554,37 +535,30 @@ const PreviewItem = ({ return ; }; -const createHeaderStripEntries = (zones: HeaderZones): HeaderStripEntry[] => [ - ...zones.left.map((item) => ({ id: getHeaderItemKey(item), item, kind: "item" as const })), - { id: "header-divider:center", kind: "divider", zone: "center" }, - ...zones.center.map((item) => ({ id: getHeaderItemKey(item), item, kind: "item" as const })), - { id: "header-divider:right", kind: "divider", zone: "right" }, - ...zones.right.map((item) => ({ id: getHeaderItemKey(item), item, kind: "item" as const })), -]; +const moveHeaderItem = ( + zones: HeaderZones, + itemKey: string, + targetZone: HeaderZoneId, + requestedTargetIndex: number, +): HeaderZones => { + let sourceItem: HeaderItem | undefined; -const createHeaderZonesFromStripEntries = (entries: HeaderStripEntry[]): HeaderZones => { - const zones: HeaderZones = { left: [], center: [], right: [] }; - let activeZone: HeaderZoneId = "left"; - - for (const entry of entries) { - if (entry.kind === "divider") { - activeZone = entry.zone; - continue; - } - zones[activeZone].push(entry.item); + for (const zone of headerGuideZones) { + sourceItem = zones[zone].find((item) => getHeaderItemKey(item) === itemKey); + if (sourceItem) break; } - return zones; -}; + if (!sourceItem) return zones; -const moveHeaderStripEntry = (entries: HeaderStripEntry[], from: number, to: number): HeaderStripEntry[] => { - if (from === to || from < 0 || from >= entries.length || to < 0 || to >= entries.length) return entries; - - const reorderedEntries = entries.slice(); - const [movedEntry] = reorderedEntries.splice(from, 1); - if (!movedEntry || movedEntry.kind !== "item") return entries; - reorderedEntries.splice(to, 0, movedEntry); - return reorderedEntries; + const nextZones: HeaderZones = { + left: zones.left.filter((item) => getHeaderItemKey(item) !== itemKey), + center: zones.center.filter((item) => getHeaderItemKey(item) !== itemKey), + right: zones.right.filter((item) => getHeaderItemKey(item) !== itemKey), + }; + const targetItems = nextZones[targetZone]; + const targetIndex = Math.max(0, Math.min(requestedTargetIndex, targetItems.length)); + nextZones[targetZone] = [...targetItems.slice(0, targetIndex), sourceItem, ...targetItems.slice(targetIndex)]; + return nextZones; }; const findHeaderItem = (items: HeaderItem[], id: string | null): HeaderItem | undefined => { diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index b20543242..e0b487f02 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -5690,7 +5690,7 @@ "header": { "title": "Header studio", "badge": "Personal", - "description": "Drag controls anywhere in the row. Center and Right are fixed boundaries; everything before Center belongs to Left.", + "description": "Drag controls anywhere in the row. Left, Center, and Right stay fixed while controls shift around them.", "device": { "desktop": "Desktop", "mobile": "Mobile"