Dismissable alert use, DataGrid rework

This commit is contained in:
2026-06-13 02:35:14 +02:00
parent 403a6722b8
commit 8d2fe5b77b
22 changed files with 207 additions and 108 deletions

View File

@@ -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 = (
<div
className={`alert ${tone}${compact ? " compact-alert" : ""}${floating ? " alert-floating" : ""} alert-dismissible ${className}`.trim()}
role={role}
@@ -46,4 +66,8 @@ export default function DismissibleAlert({
)}
</div>
);
if (!floating) return alert;
const root = getFloatingAlertRoot();
return root ? createPortal(alert, root) : alert;
}

View File

@@ -31,7 +31,7 @@ type DataGridState = {
sort?: { columnId: string; direction: DataGridSortDirection };
filters?: Record<string, string>;
widths?: Record<string, number>;
fillColumnId?: string;
fillColumnId?: string | null;
};
type DataGridProps<T> = {
@@ -62,7 +62,7 @@ export default function DataGrid<T>({
columns,
getRowKey,
emptyText = "No rows found.",
fit = "content",
fit = "container",
className = "",
rowClassName,
storageKey
@@ -74,8 +74,9 @@ export default function DataGrid<T>({
startX: number;
startWidth: number;
baseWidths: Record<string, number>;
fillColumnId?: string;
fillStartWidth?: number;
immediateRightColumnId?: string;
immediateRightStartWidth?: number;
shrinkRoomWithoutScroll: number;
} | null>(null);
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null);
const [filterPosition, setFilterPosition] = useState<FilterPosition | null>(null);
@@ -85,6 +86,13 @@ export default function DataGrid<T>({
const filterPopoverRef = useRef<HTMLDivElement | null>(null);
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
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<T>({
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<T>({
});
}, [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<T>({
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<T>(columns: DataGridColumn<T>[], 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<T>(column: DataGridColumn<T>, savedWidth?: number, stretch = false): string {
if (stretch) {
const baseWidth = savedWidth ?? fixedWidthFloor(column);
@@ -497,59 +561,54 @@ function widthForColumn<T>(column: DataGridColumn<T>, savedWidth?: number, stret
return `minmax(${column.minWidth ?? 140}px, 1fr)`;
}
function chooseStretchedColumns<T>(columns: DataGridColumn<T>[], savedWidths?: Record<string, number>, fillColumnId?: string): Set<string> {
if (columns.length === 0) return new Set();
function chooseStretchedColumns<T>(
columns: DataGridColumn<T>[],
savedWidths?: Record<string, number>,
fillColumnId?: string | null,
fit: "content" | "container" = "container",
suppressFallbackFill = false
): Set<string> {
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<T>(columns: DataGridColumn<T>[], activeColumnId?: string): string | undefined {
const activeIndex = activeColumnId ? columns.findIndex((column) => column.id === activeColumnId) : -1;
const candidateColumns = activeIndex >= 0 ? columns.slice(activeIndex + 1) : columns;
// 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<T>[]) => {
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;
function chooseLayoutFillColumn<T>(columns: DataGridColumn<T>[]): 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;
}
return undefined;
function chooseImmediateResizePartner<T>(columns: DataGridColumn<T>[], activeColumnId: string): DataGridColumn<T> | 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<T>(

View File

@@ -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({
</header>
<div className="modal-body form-grid">
<div className="login-hint">Development default: user <strong>admin@example.local</strong>, password <strong>dev-admin</strong>.</div>
{error && <div className="alert danger">{error}</div>}
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormField label="Email">
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</FormField>

View File

@@ -86,8 +86,8 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError}>{localError}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing attachments." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">

View File

@@ -23,7 +23,7 @@ export default function CampaignAuditPage({ settings, campaignId }: { settings:
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading audit data…">
<Card title="Recent audit events">

View File

@@ -156,9 +156,9 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError}>{localError}</DismissibleAlert>}
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning}>{fieldNameWarning}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing fields." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">

View File

@@ -28,7 +28,7 @@ export default function CampaignJsonView({ settings, campaignId }: { settings: A
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>Download JSON</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading JSON…">
<Card>
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}

View File

@@ -115,7 +115,7 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
<DismissibleAlert tone="warning">Sign in with your user account or configure an automation API key under Settings to load campaigns.</DismissibleAlert>
)}
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<div className="page-heading split workspace-heading">
<div>

View File

@@ -96,8 +96,8 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{message && <DismissibleAlert tone="success" resetKey={message}>{message}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading campaign overview…">
<Card title="Campaign identity">

View File

@@ -24,7 +24,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
<Button onClick={reload} disabled={loading}>Reload</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading report data…">
<div className="dashboard-grid">
<Card title="Report summary">

View File

@@ -68,8 +68,8 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError}>{localError}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing global settings." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">

View File

@@ -261,8 +261,8 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError}>{localError}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing server settings." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
@@ -331,7 +331,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
<Button variant="danger" onClick={clearMockMailbox} disabled={mailActionState === "mock" || mockMessages.length === 0}>Clear</Button>
</div>
</div>
{mockError && <DismissibleAlert tone="danger" resetKey={mockError}>{mockError}</DismissibleAlert>}
{mockError && <DismissibleAlert tone="danger" resetKey={mockError} floating>{mockError}</DismissibleAlert>}
<DataGrid
id={`campaign-${campaignId}-server-mock-mailbox`}
rows={mockMessages}
@@ -411,23 +411,23 @@ function MailActionResult({ result }: { result: MailConnectionTestResponse | nul
if (!result) return null;
const authenticated = result.details?.authenticated;
return (
<div className={`alert ${result.ok ? "success" : "danger"}`}>
<DismissibleAlert tone={result.ok ? "success" : "danger"} resetKey={`${result.ok}:${result.message}`}>
{result.message}
{result.ok && typeof authenticated === "boolean" && (
<span> Authentication: {authenticated ? "credentials accepted" : "not used"}.</span>
)}
</div>
</DismissibleAlert>
);
}
function FolderLookupResult({ result, disabled, onUseDetected }: { result: MailImapFolderListResponse | null; disabled?: boolean; onUseDetected: () => void }) {
if (!result) return null;
if (!result.ok) {
return <div className="alert danger">{result.message}</div>;
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>;
}
return (
<div className="alert success">
<DismissibleAlert tone="success" resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
<p>{result.message}</p>
<p>Detected Sent folder: <strong>{result.detected_sent_folder || "—"}</strong></p>
{result.detected_sent_folder && <Button onClick={onUseDetected} disabled={disabled}>Use detected folder</Button>}
@@ -439,6 +439,6 @@ function FolderLookupResult({ result, disabled, onUseDetected }: { result: MailI
{result.folders.length > 12 && <span className="field-chip">+{result.folders.length - 12} more</span>}
</div>
)}
</div>
</DismissibleAlert>
);
}

View File

@@ -136,8 +136,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError}>{localError}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing sender or recipient profiles." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">

View File

@@ -81,8 +81,8 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError}>{localError}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing recipient data." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">

View File

@@ -148,9 +148,9 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{sendMessage && <DismissibleAlert tone="info" resetKey={sendMessage}>{sendMessage}</DismissibleAlert>}
{mockMessage && <DismissibleAlert tone="info" resetKey={mockMessage}>{mockMessage}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{sendMessage && <DismissibleAlert tone="info" resetKey={sendMessage} floating>{sendMessage}</DismissibleAlert>}
{mockMessage && <DismissibleAlert tone="info" resetKey={mockMessage} floating>{mockMessage}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Send snapshot. Copy to edit." />}
<LoadingFrame loading={loading || queuedOrSending} label={queuedOrSending ? "Refreshing send state…" : "Loading send data…"}>

View File

@@ -142,8 +142,8 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError}>{localError}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing the template." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">

View File

@@ -107,7 +107,7 @@ export default function ReviewWorkflowCards({
return (
<>
{actionMessage && <DismissibleAlert tone="info" resetKey={actionMessage}>{actionMessage}</DismissibleAlert>}
{actionMessage && <DismissibleAlert tone="info" resetKey={actionMessage} floating>{actionMessage}</DismissibleAlert>}
<Card
title="Review actions"

View File

@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import Button from "../../../components/Button";
import DismissibleAlert from "../../../components/DismissibleAlert";
type NavigationAction = () => void;
@@ -146,7 +147,7 @@ export function CampaignUnsavedChangesProvider({ children }: { children: ReactNo
</header>
<div className="modal-body">
<p>{registration.message ?? "This campaign page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p>
{saveError && <div className="alert danger">{saveError}</div>}
{saveError && <DismissibleAlert tone="danger" resetKey={saveError}>{saveError}</DismissibleAlert>}
</div>
<footer className="modal-footer unsaved-changes-actions">
<Button onClick={() => setPendingAction(null)} disabled={saving}>Cancel</Button>

View File

@@ -117,8 +117,8 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
</div>
<div className="save-state">{saveState}</div>
</div>
{localError && <DismissibleAlert tone="danger" resetKey={localError}>{localError}</DismissibleAlert>}
{validationMessage && <DismissibleAlert tone="info" resetKey={validationMessage}>{validationMessage}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{validationMessage && <DismissibleAlert tone="info" resetKey={validationMessage} floating>{validationMessage}</DismissibleAlert>}
<Card>
{draft && activeStep === "basics" && <BasicsStep draft={draft} patch={patch} />}
{draft && activeStep === "sender" && <SenderStep draft={draft} patch={patch} />}

View File

@@ -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 = (
<div className="file-manager-toolbar" aria-label="File actions">
<Button variant="primary" onClick={() => openDialog("upload", toolbarTarget())} disabled={busy || !activeSpace}><UploadCloud size={16} aria-hidden="true" /> Upload</Button>
@@ -1119,6 +1121,9 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) {
{error && (
<DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>
)}
{message && !error && (
<DismissibleAlert tone={noticeTone} resetKey={message} floating>{message}</DismissibleAlert>
)}
<div className={`file-manager-shell ${busy ? "is-loading" : ""}`}>
<aside className="file-tree-panel" aria-label="File spaces and folders">

View File

@@ -7,6 +7,7 @@ import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch";
import { apiFetch } from "../../api/client";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
import DismissibleAlert from "../../components/DismissibleAlert";
type SettingsSection = "interface" | "workspace" | "local-connection" | "notifications";
@@ -134,7 +135,7 @@ export default function SettingsPage({ settings, onSettingsChange }: { settings:
<div className="button-row compact-actions">
<Button variant="primary" onClick={testConnection} disabled={testing}>{testing ? "Testing…" : "Test connection"}</Button>
</div>
{testResult && <div className={`alert ${testResult.startsWith("Connection successful") ? "success" : "warning"}`}>{testResult}</div>}
{testResult && <DismissibleAlert tone={testResult.startsWith("Connection successful") ? "success" : "warning"} resetKey={testResult}>{testResult}</DismissibleAlert>}
</div>
</Card>
<Card title="Session state">

View File

@@ -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 {