diff --git a/src/components/DismissibleAlert.tsx b/src/components/DismissibleAlert.tsx
index 679d9e5..f77cf57 100644
--- a/src/components/DismissibleAlert.tsx
+++ b/src/components/DismissibleAlert.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState, type ReactNode } from "react";
+import { createPortal } from "react-dom";
import { X } from "lucide-react";
type AlertTone = "success" | "info" | "warning" | "danger";
@@ -13,6 +14,26 @@ type DismissibleAlertProps = {
resetKey?: string | number;
};
+let floatingAlertRoot: HTMLElement | null = null;
+
+function getFloatingAlertRoot(): HTMLElement | null {
+ if (typeof document === "undefined") return null;
+ if (floatingAlertRoot?.isConnected) return floatingAlertRoot;
+
+ const existing = document.getElementById("app-floating-alerts");
+ if (existing) {
+ floatingAlertRoot = existing;
+ return floatingAlertRoot;
+ }
+
+ floatingAlertRoot = document.createElement("div");
+ floatingAlertRoot.id = "app-floating-alerts";
+ floatingAlertRoot.className = "alert-floating-stack";
+ floatingAlertRoot.setAttribute("aria-label", "Application notices");
+ document.body.appendChild(floatingAlertRoot);
+ return floatingAlertRoot;
+}
+
export default function DismissibleAlert({
tone = "info",
children,
@@ -31,8 +52,7 @@ export default function DismissibleAlert({
if (!visible) return null;
const role = tone === "danger" || tone === "warning" ? "alert" : "status";
-
- return (
+ const alert = (
);
+
+ if (!floating) return alert;
+ const root = getFloatingAlertRoot();
+ return root ? createPortal(alert, root) : alert;
}
diff --git a/src/components/table/DataGrid.tsx b/src/components/table/DataGrid.tsx
index 95509cc..9ee149c 100644
--- a/src/components/table/DataGrid.tsx
+++ b/src/components/table/DataGrid.tsx
@@ -31,7 +31,7 @@ type DataGridState = {
sort?: { columnId: string; direction: DataGridSortDirection };
filters?: Record
;
widths?: Record;
- fillColumnId?: string;
+ fillColumnId?: string | null;
};
type DataGridProps = {
@@ -62,7 +62,7 @@ export default function DataGrid({
columns,
getRowKey,
emptyText = "No rows found.",
- fit = "content",
+ fit = "container",
className = "",
rowClassName,
storageKey
@@ -74,8 +74,9 @@ export default function DataGrid({
startX: number;
startWidth: number;
baseWidths: Record;
- fillColumnId?: string;
- fillStartWidth?: number;
+ immediateRightColumnId?: string;
+ immediateRightStartWidth?: number;
+ shrinkRoomWithoutScroll: number;
} | null>(null);
const [openFilterColumnId, setOpenFilterColumnId] = useState(null);
const [filterPosition, setFilterPosition] = useState(null);
@@ -85,6 +86,13 @@ export default function DataGrid({
const filterPopoverRef = useRef(null);
const [measuredWidths, setMeasuredWidths] = useState>({});
+ useEffect(() => {
+ // Repair persisted state from older resize implementations. Fixed columns
+ // never own user-resizable widths, and only a resizable, non-sticky column
+ // may be retained as the layout filler.
+ setState((current) => sanitizePersistedColumnState(columns, current));
+ }, [columns]);
+
useEffect(() => {
try {
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
@@ -141,40 +149,58 @@ export default function DataGrid({
useEffect(() => {
if (!resizeState) return;
const activeResize = resizeState;
+
function onMove(event: MouseEvent) {
const column = columns.find((item) => item.id === activeResize.columnId);
- const minWidth = column?.minWidth ?? 80;
- const maxWidth = column?.maxWidth ?? 2000;
- const rawDelta = event.clientX - activeResize.startX;
- const fillColumn = activeResize.fillColumnId
- ? columns.find((item) => item.id === activeResize.fillColumnId)
- : undefined;
- const nextWidths = { ...activeResize.baseWidths };
+ if (!column) return;
- if (fillColumn && fillColumn.id !== activeResize.columnId) {
- const fillMinWidth = fillColumn.minWidth ?? 80;
- const fillMaxWidth = fillColumn.maxWidth ?? 2000;
- const fillStartWidth = activeResize.fillStartWidth
- ?? activeResize.baseWidths[fillColumn.id]
- ?? columnPixelWidth(fillColumn, activeResize.baseWidths[fillColumn.id], measuredWidths[fillColumn.id]);
- const minDelta = Math.max(minWidth - activeResize.startWidth, fillStartWidth - fillMaxWidth);
- const maxDelta = Math.min(maxWidth - activeResize.startWidth, fillStartWidth - fillMinWidth);
- const boundedDelta = Math.min(maxDelta, Math.max(minDelta, rawDelta));
- nextWidths[activeResize.columnId] = activeResize.startWidth + boundedDelta;
- nextWidths[fillColumn.id] = fillStartWidth - boundedDelta;
+ const minWidth = column.minWidth ?? 80;
+ const maxWidth = column.maxWidth ?? 2000;
+ const rawDelta = event.clientX - activeResize.startX;
+ const activeDelta = Math.min(
+ maxWidth - activeResize.startWidth,
+ Math.max(minWidth - activeResize.startWidth, rawDelta)
+ );
+ const nextWidths = { ...activeResize.baseWidths };
+ let fillColumnId: string | null | undefined;
+
+ if (activeDelta > 0 && activeResize.immediateRightColumnId) {
+ // A growing column may only shrink its immediate neighbour. Once that
+ // neighbour reaches its minimum, the grid grows and can scroll rather
+ // than pulling space from a more distant column.
+ const neighbour = columns.find((item) => item.id === activeResize.immediateRightColumnId);
+ const neighbourStartWidth = activeResize.immediateRightStartWidth ?? 0;
+ const neighbourMinWidth = neighbour?.minWidth ?? 80;
+ const absorbedDelta = Math.min(activeDelta, Math.max(0, neighbourStartWidth - neighbourMinWidth));
+ nextWidths[activeResize.columnId] = activeResize.startWidth + activeDelta;
+ nextWidths[activeResize.immediateRightColumnId] = neighbourStartWidth - absorbedDelta;
+ fillColumnId = activeResize.immediateRightColumnId;
} else {
- nextWidths[activeResize.columnId] = Math.min(maxWidth, Math.max(minWidth, activeResize.startWidth + rawDelta));
+ // Shrinking never resizes another column. It consumes only horizontal
+ // overflow that is still available to the right of the viewport. Once
+ // that room is exhausted, resizing stops instead of growing a buffer
+ // column, reclaiming width from the left, or shifting the scroll view.
+ const boundedDelta = activeDelta < 0
+ ? Math.max(activeDelta, -activeResize.shrinkRoomWithoutScroll)
+ : activeDelta;
+ nextWidths[activeResize.columnId] = activeResize.startWidth + boundedDelta;
+ // Exact pixel tracks remain fixed during the drag, so no implicit
+ // flexible filler can change a different column behind the pointer.
+ fillColumnId = null;
}
setState((current) => ({
...current,
widths: nextWidths,
- fillColumnId: activeResize.fillColumnId
+ fillColumnId
}));
}
+
function onUp() {
+ setState((current) => sanitizePersistedColumnState(columns, current));
setResizeState(null);
}
+
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
@@ -246,7 +272,16 @@ export default function DataGrid({
});
}, [rows, columns, state.filters, state.sort, filterTypes]);
- const stretchedColumnIds = useMemo(() => chooseStretchedColumns(columns, state.widths, state.fillColumnId), [columns, state.widths, state.fillColumnId]);
+ const stretchedColumnIds = useMemo(
+ () => chooseStretchedColumns(
+ columns,
+ state.widths,
+ state.fillColumnId,
+ fit,
+ Boolean(resizeState && !state.fillColumnId)
+ ),
+ [columns, state.widths, state.fillColumnId, fit, resizeState]
+ );
const templateColumns = columns.map((column) => widthForColumn(column, state.widths?.[column.id], stretchedColumnIds.has(column.id))).join(" ");
const hasFlexibleColumns = columns.some((column) => stretchedColumnIds.has(column.id) || isFlexibleColumn(column, state.widths?.[column.id]));
const stickyOffsets = useMemo(() => computeStickyOffsets(columns, state.widths, measuredWidths), [columns, state.widths, measuredWidths]);
@@ -335,19 +370,29 @@ export default function DataGrid({
event.stopPropagation();
const baseWidths = measuredColumnWidths(columns, headerCellRefs.current, state.widths, measuredWidths);
const currentWidth = baseWidths[column.id] ?? columnPixelWidth(column, state.widths?.[column.id], measuredWidths[column.id]);
- const fillColumnId = chooseResizeFillColumn(columns, column.id);
- const fillColumn = fillColumnId ? columns.find((item) => item.id === fillColumnId) : undefined;
- const fillStartWidth = fillColumn
- ? baseWidths[fillColumn.id] ?? columnPixelWidth(fillColumn, state.widths?.[fillColumn.id], measuredWidths[fillColumn.id])
+ const immediateRightColumn = chooseImmediateResizePartner(columns, column.id);
+ const immediateRightStartWidth = immediateRightColumn
+ ? baseWidths[immediateRightColumn.id] ?? columnPixelWidth(immediateRightColumn, state.widths?.[immediateRightColumn.id], measuredWidths[immediateRightColumn.id])
: undefined;
- setState((current) => ({ ...current, widths: { ...baseWidths }, fillColumnId }));
+ const gridElement = gridRef.current;
+ const shrinkRoomWithoutScroll = gridElement
+ ? Math.max(0, gridElement.scrollWidth - gridElement.clientWidth - gridElement.scrollLeft)
+ : 0;
+ setState((current) => ({
+ ...current,
+ widths: { ...baseWidths },
+ // Exact measured tracks remain stable during the drag.
+ // No flexible column is allowed to become an implicit partner.
+ fillColumnId: undefined
+ }));
setResizeState({
columnId: column.id,
startX: event.clientX,
startWidth: currentWidth,
baseWidths,
- fillColumnId,
- fillStartWidth
+ immediateRightColumnId: immediateRightColumn?.id,
+ immediateRightStartWidth,
+ shrinkRoomWithoutScroll
});
}}
>
@@ -486,6 +531,25 @@ function loadState(key: string): DataGridState {
}
}
+function sanitizePersistedColumnState(columns: DataGridColumn[], state: DataGridState): DataGridState {
+ const resizableColumnIds = new Set(columns.filter((column) => column.resizable).map((column) => column.id));
+ const nextWidths = Object.fromEntries(
+ Object.entries(state.widths ?? {}).filter(([columnId]) => resizableColumnIds.has(columnId))
+ );
+ const fillColumn = state.fillColumnId
+ ? columns.find((column) => column.id === state.fillColumnId)
+ : undefined;
+ const nextFillColumnId = fillColumn?.resizable && !fillColumn.sticky ? fillColumn.id : undefined;
+ const widthsChanged = Object.keys(nextWidths).length !== Object.keys(state.widths ?? {}).length;
+ const fillChanged = nextFillColumnId !== state.fillColumnId;
+ if (!widthsChanged && !fillChanged) return state;
+ return {
+ ...state,
+ widths: Object.keys(nextWidths).length > 0 ? nextWidths : undefined,
+ fillColumnId: nextFillColumnId
+ };
+}
+
function widthForColumn(column: DataGridColumn, savedWidth?: number, stretch = false): string {
if (stretch) {
const baseWidth = savedWidth ?? fixedWidthFloor(column);
@@ -497,59 +561,54 @@ function widthForColumn(column: DataGridColumn, savedWidth?: number, stret
return `minmax(${column.minWidth ?? 140}px, 1fr)`;
}
-function chooseStretchedColumns(columns: DataGridColumn[], savedWidths?: Record, fillColumnId?: string): Set {
- if (columns.length === 0) return new Set();
+function chooseStretchedColumns(
+ columns: DataGridColumn[],
+ savedWidths?: Record,
+ fillColumnId?: string | null,
+ fit: "content" | "container" = "container",
+ suppressFallbackFill = false
+): Set {
+ if (fit === "content" || columns.length === 0 || fillColumnId === null) return new Set();
const savedColumnIds = new Set(Object.keys(savedWidths ?? {}));
+ if (suppressFallbackFill && savedColumnIds.size > 0 && !fillColumnId) return new Set();
if (savedColumnIds.size > 0) {
- const preferredFillColumnId = chooseResizeFillColumn(columns);
- if (fillColumnId && fillColumnId === preferredFillColumnId && columns.some((column) => column.id === fillColumnId)) {
- return new Set([fillColumnId]);
- }
+ const requestedFill = columns.find((column) => column.id === fillColumnId && !column.sticky);
+ if (requestedFill) return new Set([requestedFill.id]);
const unsizedNonStickyResizable = columns.filter((column) => column.resizable && !column.sticky && !savedColumnIds.has(column.id));
if (unsizedNonStickyResizable.length > 0) return new Set(unsizedNonStickyResizable.map((column) => column.id));
- const unsizedFlexible = columns.filter((column) => !savedColumnIds.has(column.id) && isFlexibleColumn(column, savedWidths?.[column.id]));
+ const unsizedFlexible = columns.filter((column) => !column.sticky && !savedColumnIds.has(column.id) && isFlexibleColumn(column, savedWidths?.[column.id]));
if (unsizedFlexible.length > 0) return new Set(unsizedFlexible.map((column) => column.id));
- const fallback = chooseResizeFillColumn(columns);
+ const fallback = chooseLayoutFillColumn(columns);
return new Set(fallback ? [fallback] : []);
}
const nonStickyResizable = columns.filter((column) => column.resizable && !column.sticky);
if (nonStickyResizable.length > 0) return new Set(nonStickyResizable.map((column) => column.id));
- const flexible = columns.filter((column) => isFlexibleColumn(column, savedWidths?.[column.id]));
- if (flexible.length > 0) return new Set();
+ const flexible = columns.filter((column) => !column.sticky && isFlexibleColumn(column, savedWidths?.[column.id]));
+ if (flexible.length > 0) return new Set(flexible.map((column) => column.id));
- const fallback = chooseResizeFillColumn(columns);
+ // Fixed-width tables still fill their container by default. Prefer the last
+ // ordinary data column so sticky action columns remain visually stable.
+ const fallback = chooseLayoutFillColumn(columns);
return new Set(fallback ? [fallback] : []);
}
-function chooseResizeFillColumn(columns: DataGridColumn[], activeColumnId?: string): string | undefined {
- const activeIndex = activeColumnId ? columns.findIndex((column) => column.id === activeColumnId) : -1;
- const candidateColumns = activeIndex >= 0 ? columns.slice(activeIndex + 1) : columns;
+function chooseLayoutFillColumn(columns: DataGridColumn[]): string | undefined {
+ const nonSticky = columns.filter((column) => !column.sticky);
+ const resizable = nonSticky.filter((column) => column.resizable);
+ return (resizable[resizable.length - 1] ?? nonSticky[nonSticky.length - 1])?.id;
+}
- // Only resizable, non-sticky columns are allowed to absorb spare width. This keeps
- // fixed/action columns fixed. During active resize the absorber must be to the right
- // of the dragged column, so columns on the left never move. If there is no eligible
- // column to the right, the dragged column itself may stay stretched; otherwise no
- // absorber is selected and the grid may grow/shrink naturally.
- const rightmostResizableNonSticky = (items: DataGridColumn[]) => {
- const candidates = items.filter((column) => column.resizable && !column.sticky);
- return candidates[candidates.length - 1];
- };
-
- const rightFillColumn = rightmostResizableNonSticky(candidateColumns);
- if (rightFillColumn) return rightFillColumn.id;
-
- if (activeIndex >= 0) {
- const activeColumn = columns[activeIndex];
- if (activeColumn?.resizable && !activeColumn.sticky) return activeColumn.id;
- }
-
- return undefined;
+function chooseImmediateResizePartner(columns: DataGridColumn[], activeColumnId: string): DataGridColumn | undefined {
+ const activeIndex = columns.findIndex((column) => column.id === activeColumnId);
+ if (activeIndex < 0) return undefined;
+ const neighbour = columns[activeIndex + 1];
+ return neighbour?.resizable && !neighbour.sticky ? neighbour : undefined;
}
function measuredColumnWidths(
diff --git a/src/features/auth/LoginModal.tsx b/src/features/auth/LoginModal.tsx
index c367ddf..e6ad8ce 100644
--- a/src/features/auth/LoginModal.tsx
+++ b/src/features/auth/LoginModal.tsx
@@ -3,6 +3,7 @@ import type { ApiSettings, LoginResponse } from "../../types";
import { login } from "../../api/auth";
import Button from "../../components/Button";
import FormField from "../../components/FormField";
+import DismissibleAlert from "../../components/DismissibleAlert";
export default function LoginModal({
settings,
@@ -42,7 +43,7 @@ export default function LoginModal({
Development default: user admin@example.local , password dev-admin .
- {error &&
{error}
}
+ {error &&
{error} }
setEmail(e.target.value)} />
diff --git a/src/features/campaigns/AttachmentsDataPage.tsx b/src/features/campaigns/AttachmentsDataPage.tsx
index b4c9a50..b1ef58e 100644
--- a/src/features/campaigns/AttachmentsDataPage.tsx
+++ b/src/features/campaigns/AttachmentsDataPage.tsx
@@ -86,8 +86,8 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
- {error && {error} }
- {localError && {localError} }
+ {error && {error} }
+ {localError && {localError} }
{locked && }
diff --git a/src/features/campaigns/CampaignAuditPage.tsx b/src/features/campaigns/CampaignAuditPage.tsx
index 62cca0a..e18cf33 100644
--- a/src/features/campaigns/CampaignAuditPage.tsx
+++ b/src/features/campaigns/CampaignAuditPage.tsx
@@ -23,7 +23,7 @@ export default function CampaignAuditPage({ settings, campaignId }: { settings:
- {error && {error} }
+ {error && {error} }
diff --git a/src/features/campaigns/CampaignFieldsPage.tsx b/src/features/campaigns/CampaignFieldsPage.tsx
index a2f80c3..7e3954d 100644
--- a/src/features/campaigns/CampaignFieldsPage.tsx
+++ b/src/features/campaigns/CampaignFieldsPage.tsx
@@ -156,9 +156,9 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
- {error && {error} }
- {localError && {localError} }
- {fieldNameWarning && {fieldNameWarning} }
+ {error && {error} }
+ {localError && {localError} }
+ {fieldNameWarning && {fieldNameWarning} }
{locked && }
diff --git a/src/features/campaigns/CampaignJsonView.tsx b/src/features/campaigns/CampaignJsonView.tsx
index a9c1891..a98bf72 100644
--- a/src/features/campaigns/CampaignJsonView.tsx
+++ b/src/features/campaigns/CampaignJsonView.tsx
@@ -28,7 +28,7 @@ export default function CampaignJsonView({ settings, campaignId }: { settings: A
downloadJson(filename, campaignJson)} disabled={!version}>Download JSON
- {error && {error} }
+ {error && {error} }
{!loading || version ? {JSON.stringify(campaignJson, null, 2)} : {"{}"} }
diff --git a/src/features/campaigns/CampaignListPage.tsx b/src/features/campaigns/CampaignListPage.tsx
index 0effb18..e4c1195 100644
--- a/src/features/campaigns/CampaignListPage.tsx
+++ b/src/features/campaigns/CampaignListPage.tsx
@@ -115,7 +115,7 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
Sign in with your user account or configure an automation API key under Settings to load campaigns.
)}
- {error && {error} }
+ {error && {error} }
diff --git a/src/features/campaigns/CampaignOverviewPage.tsx b/src/features/campaigns/CampaignOverviewPage.tsx
index b182e98..3abd381 100644
--- a/src/features/campaigns/CampaignOverviewPage.tsx
+++ b/src/features/campaigns/CampaignOverviewPage.tsx
@@ -96,8 +96,8 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
- {error && {error} }
- {message && {message} }
+ {error && {error} }
+ {message && {message} }
diff --git a/src/features/campaigns/CampaignReportPage.tsx b/src/features/campaigns/CampaignReportPage.tsx
index 4509393..dfdf36b 100644
--- a/src/features/campaigns/CampaignReportPage.tsx
+++ b/src/features/campaigns/CampaignReportPage.tsx
@@ -24,7 +24,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
Reload
- {error && {error} }
+ {error && {error} }
diff --git a/src/features/campaigns/GlobalSettingsPage.tsx b/src/features/campaigns/GlobalSettingsPage.tsx
index af7d832..84835ec 100644
--- a/src/features/campaigns/GlobalSettingsPage.tsx
+++ b/src/features/campaigns/GlobalSettingsPage.tsx
@@ -68,8 +68,8 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
- {error && {error} }
- {localError && {localError} }
+ {error && {error} }
+ {localError && {localError} }
{locked && }
diff --git a/src/features/campaigns/MailSettingsPage.tsx b/src/features/campaigns/MailSettingsPage.tsx
index ecb4f00..294cea3 100644
--- a/src/features/campaigns/MailSettingsPage.tsx
+++ b/src/features/campaigns/MailSettingsPage.tsx
@@ -261,8 +261,8 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
- {error && {error} }
- {localError && {localError} }
+ {error && {error} }
+ {localError && {localError} }
{locked && }
@@ -331,7 +331,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
Clear
- {mockError && {mockError} }
+ {mockError && {mockError} }
+
{result.message}
{result.ok && typeof authenticated === "boolean" && (
Authentication: {authenticated ? "credentials accepted" : "not used"}.
)}
-
+
);
}
function FolderLookupResult({ result, disabled, onUseDetected }: { result: MailImapFolderListResponse | null; disabled?: boolean; onUseDetected: () => void }) {
if (!result) return null;
if (!result.ok) {
- return {result.message}
;
+ return {result.message} ;
}
return (
-
+
{result.message}
Detected Sent folder: {result.detected_sent_folder || "—"}
{result.detected_sent_folder && Use detected folder }
@@ -439,6 +439,6 @@ function FolderLookupResult({ result, disabled, onUseDetected }: { result: MailI
{result.folders.length > 12 && +{result.folders.length - 12} more }
)}
-
+
);
}
\ No newline at end of file
diff --git a/src/features/campaigns/RecipientDataPage.tsx b/src/features/campaigns/RecipientDataPage.tsx
index 961bf75..6572df1 100644
--- a/src/features/campaigns/RecipientDataPage.tsx
+++ b/src/features/campaigns/RecipientDataPage.tsx
@@ -136,8 +136,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
- {error && {error} }
- {localError && {localError} }
+ {error && {error} }
+ {localError && {localError} }
{locked && }
diff --git a/src/features/campaigns/RecipientDetailsPage.tsx b/src/features/campaigns/RecipientDetailsPage.tsx
index 17657af..8204948 100644
--- a/src/features/campaigns/RecipientDetailsPage.tsx
+++ b/src/features/campaigns/RecipientDetailsPage.tsx
@@ -81,8 +81,8 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
- {error && {error} }
- {localError && {localError} }
+ {error && {error} }
+ {localError && {localError} }
{locked && }
diff --git a/src/features/campaigns/SendDataPage.tsx b/src/features/campaigns/SendDataPage.tsx
index f2422b3..7b747bb 100644
--- a/src/features/campaigns/SendDataPage.tsx
+++ b/src/features/campaigns/SendDataPage.tsx
@@ -148,9 +148,9 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
- {error && {error} }
- {sendMessage && {sendMessage} }
- {mockMessage && {mockMessage} }
+ {error && {error} }
+ {sendMessage && {sendMessage} }
+ {mockMessage && {mockMessage} }
{locked && }
diff --git a/src/features/campaigns/TemplateDataPage.tsx b/src/features/campaigns/TemplateDataPage.tsx
index 0daab5a..e77b2f6 100644
--- a/src/features/campaigns/TemplateDataPage.tsx
+++ b/src/features/campaigns/TemplateDataPage.tsx
@@ -142,8 +142,8 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
- {error && {error} }
- {localError && {localError} }
+ {error && {error} }
+ {localError && {localError} }
{locked && }
diff --git a/src/features/campaigns/components/ReviewWorkflowCards.tsx b/src/features/campaigns/components/ReviewWorkflowCards.tsx
index 50528d0..1341561 100644
--- a/src/features/campaigns/components/ReviewWorkflowCards.tsx
+++ b/src/features/campaigns/components/ReviewWorkflowCards.tsx
@@ -107,7 +107,7 @@ export default function ReviewWorkflowCards({
return (
<>
- {actionMessage && {actionMessage} }
+ {actionMessage && {actionMessage} }
void;
@@ -146,7 +147,7 @@ export function CampaignUnsavedChangesProvider({ children }: { children: ReactNo
{registration.message ?? "This campaign page has unsaved changes. Save them before leaving, or discard the changes and continue."}
- {saveError &&
{saveError}
}
+ {saveError &&
{saveError} }
setPendingAction(null)} disabled={saving}>Cancel
diff --git a/src/features/campaigns/wizard/CreateWizard.tsx b/src/features/campaigns/wizard/CreateWizard.tsx
index a76694f..a614abc 100644
--- a/src/features/campaigns/wizard/CreateWizard.tsx
+++ b/src/features/campaigns/wizard/CreateWizard.tsx
@@ -117,8 +117,8 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
{saveState}
- {localError && {localError} }
- {validationMessage && {validationMessage} }
+ {localError && {localError} }
+ {validationMessage && {validationMessage} }
{draft && activeStep === "basics" && }
{draft && activeStep === "sender" && }
diff --git a/src/features/files/FilesPage.tsx b/src/features/files/FilesPage.tsx
index d21069f..40a918f 100644
--- a/src/features/files/FilesPage.tsx
+++ b/src/features/files/FilesPage.tsx
@@ -1102,6 +1102,8 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) {
return `${label} ${sortDirection === "asc" ? "↑" : "↓"}`;
}
+ const noticeTone = message.startsWith("No files") ? "warning" : message.endsWith("…") ? "info" : "success";
+
const toolbar = (
openDialog("upload", toolbarTarget())} disabled={busy || !activeSpace}> Upload
@@ -1119,6 +1121,9 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) {
{error && (
{error}
)}
+ {message && !error && (
+
{message}
+ )}
diff --git a/src/styles/components.css b/src/styles/components.css
index bc74b5a..502dc91 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -693,9 +693,7 @@
align-items: flex-start;
justify-content: space-between;
gap: 12px;
- position: sticky;
- top: 10px;
- z-index: 30;
+ position: relative;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
}
@@ -704,17 +702,27 @@
flex: 1 1 auto;
}
-.alert-floating {
+.alert-floating-stack {
position: fixed;
top: 131px;
left: 50%;
z-index: 1200;
+ display: grid;
+ gap: 10px;
width: min(640px, calc(100vw - 32px));
- margin: 0;
transform: translateX(-50%);
+ pointer-events: none;
+}
+
+.alert-floating {
+ position: relative;
+ top: auto;
+ width: 100%;
+ margin: 0;
border: 1px solid rgba(15, 23, 42, 0.14);
border-radius: 12px;
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.2);
+ pointer-events: auto;
}
.alert-dismiss {