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 { useEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react"; import { X } from "lucide-react";
type AlertTone = "success" | "info" | "warning" | "danger"; type AlertTone = "success" | "info" | "warning" | "danger";
@@ -13,6 +14,26 @@ type DismissibleAlertProps = {
resetKey?: string | number; 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({ export default function DismissibleAlert({
tone = "info", tone = "info",
children, children,
@@ -31,8 +52,7 @@ export default function DismissibleAlert({
if (!visible) return null; if (!visible) return null;
const role = tone === "danger" || tone === "warning" ? "alert" : "status"; const role = tone === "danger" || tone === "warning" ? "alert" : "status";
const alert = (
return (
<div <div
className={`alert ${tone}${compact ? " compact-alert" : ""}${floating ? " alert-floating" : ""} alert-dismissible ${className}`.trim()} className={`alert ${tone}${compact ? " compact-alert" : ""}${floating ? " alert-floating" : ""} alert-dismissible ${className}`.trim()}
role={role} role={role}
@@ -46,4 +66,8 @@ export default function DismissibleAlert({
)} )}
</div> </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 }; sort?: { columnId: string; direction: DataGridSortDirection };
filters?: Record<string, string>; filters?: Record<string, string>;
widths?: Record<string, number>; widths?: Record<string, number>;
fillColumnId?: string; fillColumnId?: string | null;
}; };
type DataGridProps<T> = { type DataGridProps<T> = {
@@ -62,7 +62,7 @@ export default function DataGrid<T>({
columns, columns,
getRowKey, getRowKey,
emptyText = "No rows found.", emptyText = "No rows found.",
fit = "content", fit = "container",
className = "", className = "",
rowClassName, rowClassName,
storageKey storageKey
@@ -74,8 +74,9 @@ export default function DataGrid<T>({
startX: number; startX: number;
startWidth: number; startWidth: number;
baseWidths: Record<string, number>; baseWidths: Record<string, number>;
fillColumnId?: string; immediateRightColumnId?: string;
fillStartWidth?: number; immediateRightStartWidth?: number;
shrinkRoomWithoutScroll: number;
} | null>(null); } | null>(null);
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null); const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null);
const [filterPosition, setFilterPosition] = useState<FilterPosition | 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 filterPopoverRef = useRef<HTMLDivElement | null>(null);
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({}); 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(() => { useEffect(() => {
try { try {
window.localStorage.setItem(localStorageKey, JSON.stringify(state)); window.localStorage.setItem(localStorageKey, JSON.stringify(state));
@@ -141,40 +149,58 @@ export default function DataGrid<T>({
useEffect(() => { useEffect(() => {
if (!resizeState) return; if (!resizeState) return;
const activeResize = resizeState; const activeResize = resizeState;
function onMove(event: MouseEvent) { function onMove(event: MouseEvent) {
const column = columns.find((item) => item.id === activeResize.columnId); const column = columns.find((item) => item.id === activeResize.columnId);
const minWidth = column?.minWidth ?? 80; if (!column) return;
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 (fillColumn && fillColumn.id !== activeResize.columnId) { const minWidth = column.minWidth ?? 80;
const fillMinWidth = fillColumn.minWidth ?? 80; const maxWidth = column.maxWidth ?? 2000;
const fillMaxWidth = fillColumn.maxWidth ?? 2000; const rawDelta = event.clientX - activeResize.startX;
const fillStartWidth = activeResize.fillStartWidth const activeDelta = Math.min(
?? activeResize.baseWidths[fillColumn.id] maxWidth - activeResize.startWidth,
?? columnPixelWidth(fillColumn, activeResize.baseWidths[fillColumn.id], measuredWidths[fillColumn.id]); Math.max(minWidth - activeResize.startWidth, rawDelta)
const minDelta = Math.max(minWidth - activeResize.startWidth, fillStartWidth - fillMaxWidth); );
const maxDelta = Math.min(maxWidth - activeResize.startWidth, fillStartWidth - fillMinWidth); const nextWidths = { ...activeResize.baseWidths };
const boundedDelta = Math.min(maxDelta, Math.max(minDelta, rawDelta)); let fillColumnId: string | null | undefined;
nextWidths[activeResize.columnId] = activeResize.startWidth + boundedDelta;
nextWidths[fillColumn.id] = fillStartWidth - boundedDelta; 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 { } 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) => ({ setState((current) => ({
...current, ...current,
widths: nextWidths, widths: nextWidths,
fillColumnId: activeResize.fillColumnId fillColumnId
})); }));
} }
function onUp() { function onUp() {
setState((current) => sanitizePersistedColumnState(columns, current));
setResizeState(null); setResizeState(null);
} }
window.addEventListener("mousemove", onMove); window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp); window.addEventListener("mouseup", onUp);
return () => { return () => {
@@ -246,7 +272,16 @@ export default function DataGrid<T>({
}); });
}, [rows, columns, state.filters, state.sort, filterTypes]); }, [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 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 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]); const stickyOffsets = useMemo(() => computeStickyOffsets(columns, state.widths, measuredWidths), [columns, state.widths, measuredWidths]);
@@ -335,19 +370,29 @@ export default function DataGrid<T>({
event.stopPropagation(); event.stopPropagation();
const baseWidths = measuredColumnWidths(columns, headerCellRefs.current, state.widths, measuredWidths); const baseWidths = measuredColumnWidths(columns, headerCellRefs.current, state.widths, measuredWidths);
const currentWidth = baseWidths[column.id] ?? columnPixelWidth(column, state.widths?.[column.id], measuredWidths[column.id]); const currentWidth = baseWidths[column.id] ?? columnPixelWidth(column, state.widths?.[column.id], measuredWidths[column.id]);
const fillColumnId = chooseResizeFillColumn(columns, column.id); const immediateRightColumn = chooseImmediateResizePartner(columns, column.id);
const fillColumn = fillColumnId ? columns.find((item) => item.id === fillColumnId) : undefined; const immediateRightStartWidth = immediateRightColumn
const fillStartWidth = fillColumn ? baseWidths[immediateRightColumn.id] ?? columnPixelWidth(immediateRightColumn, state.widths?.[immediateRightColumn.id], measuredWidths[immediateRightColumn.id])
? baseWidths[fillColumn.id] ?? columnPixelWidth(fillColumn, state.widths?.[fillColumn.id], measuredWidths[fillColumn.id])
: undefined; : 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({ setResizeState({
columnId: column.id, columnId: column.id,
startX: event.clientX, startX: event.clientX,
startWidth: currentWidth, startWidth: currentWidth,
baseWidths, baseWidths,
fillColumnId, immediateRightColumnId: immediateRightColumn?.id,
fillStartWidth 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 { function widthForColumn<T>(column: DataGridColumn<T>, savedWidth?: number, stretch = false): string {
if (stretch) { if (stretch) {
const baseWidth = savedWidth ?? fixedWidthFloor(column); 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)`; return `minmax(${column.minWidth ?? 140}px, 1fr)`;
} }
function chooseStretchedColumns<T>(columns: DataGridColumn<T>[], savedWidths?: Record<string, number>, fillColumnId?: string): Set<string> { function chooseStretchedColumns<T>(
if (columns.length === 0) return new Set(); 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 ?? {})); const savedColumnIds = new Set(Object.keys(savedWidths ?? {}));
if (suppressFallbackFill && savedColumnIds.size > 0 && !fillColumnId) return new Set();
if (savedColumnIds.size > 0) { if (savedColumnIds.size > 0) {
const preferredFillColumnId = chooseResizeFillColumn(columns); const requestedFill = columns.find((column) => column.id === fillColumnId && !column.sticky);
if (fillColumnId && fillColumnId === preferredFillColumnId && columns.some((column) => column.id === fillColumnId)) { if (requestedFill) return new Set([requestedFill.id]);
return new Set([fillColumnId]);
}
const unsizedNonStickyResizable = columns.filter((column) => column.resizable && !column.sticky && !savedColumnIds.has(column.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)); 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)); 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] : []); return new Set(fallback ? [fallback] : []);
} }
const nonStickyResizable = columns.filter((column) => column.resizable && !column.sticky); const nonStickyResizable = columns.filter((column) => column.resizable && !column.sticky);
if (nonStickyResizable.length > 0) return new Set(nonStickyResizable.map((column) => column.id)); if (nonStickyResizable.length > 0) return new Set(nonStickyResizable.map((column) => column.id));
const flexible = columns.filter((column) => isFlexibleColumn(column, savedWidths?.[column.id])); const flexible = columns.filter((column) => !column.sticky && isFlexibleColumn(column, savedWidths?.[column.id]));
if (flexible.length > 0) return new Set(); 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] : []); return new Set(fallback ? [fallback] : []);
} }
function chooseResizeFillColumn<T>(columns: DataGridColumn<T>[], activeColumnId?: string): string | undefined { function chooseLayoutFillColumn<T>(columns: DataGridColumn<T>[]): string | undefined {
const activeIndex = activeColumnId ? columns.findIndex((column) => column.id === activeColumnId) : -1; const nonSticky = columns.filter((column) => !column.sticky);
const candidateColumns = activeIndex >= 0 ? columns.slice(activeIndex + 1) : columns; 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 function chooseImmediateResizePartner<T>(columns: DataGridColumn<T>[], activeColumnId: string): DataGridColumn<T> | undefined {
// fixed/action columns fixed. During active resize the absorber must be to the right const activeIndex = columns.findIndex((column) => column.id === activeColumnId);
// of the dragged column, so columns on the left never move. If there is no eligible if (activeIndex < 0) return undefined;
// column to the right, the dragged column itself may stay stretched; otherwise no const neighbour = columns[activeIndex + 1];
// absorber is selected and the grid may grow/shrink naturally. return neighbour?.resizable && !neighbour.sticky ? neighbour : undefined;
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;
}
return undefined;
} }
function measuredColumnWidths<T>( function measuredColumnWidths<T>(

View File

@@ -3,6 +3,7 @@ import type { ApiSettings, LoginResponse } from "../../types";
import { login } from "../../api/auth"; import { login } from "../../api/auth";
import Button from "../../components/Button"; import Button from "../../components/Button";
import FormField from "../../components/FormField"; import FormField from "../../components/FormField";
import DismissibleAlert from "../../components/DismissibleAlert";
export default function LoginModal({ export default function LoginModal({
settings, settings,
@@ -42,7 +43,7 @@ export default function LoginModal({
</header> </header>
<div className="modal-body form-grid"> <div className="modal-body form-grid">
<div className="login-hint">Development default: user <strong>admin@example.local</strong>, password <strong>dev-admin</strong>.</div> <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"> <FormField label="Email">
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</FormField> </FormField>

View File

@@ -86,8 +86,8 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError}>{localError}</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." />} {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…"> <LoadingFrame loading={loading || !draft} label="Loading campaign draft…">

View File

@@ -23,7 +23,7 @@ export default function CampaignAuditPage({ settings, campaignId }: { settings:
</div> </div>
</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…"> <LoadingFrame loading={loading} label="Loading audit data…">
<Card title="Recent audit events"> <Card title="Recent audit events">

View File

@@ -156,9 +156,9 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError}>{localError}</DismissibleAlert>} {localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning}>{fieldNameWarning}</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." />} {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…"> <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> <Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>Download JSON</Button>
</div> </div>
</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…"> <LoadingFrame loading={loading} label="Loading JSON…">
<Card> <Card>
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>} {!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> <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 className="page-heading split workspace-heading">
<div> <div>

View File

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

View File

@@ -24,7 +24,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
<Button onClick={reload} disabled={loading}>Reload</Button> <Button onClick={reload} disabled={loading}>Reload</Button>
</div> </div>
</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…"> <LoadingFrame loading={loading} label="Loading report data…">
<div className="dashboard-grid"> <div className="dashboard-grid">
<Card title="Report summary"> <Card title="Report summary">

View File

@@ -68,8 +68,8 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError}>{localError}</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." />} {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…"> <LoadingFrame loading={loading || !draft} label="Loading campaign draft…">

View File

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

View File

@@ -136,8 +136,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError}>{localError}</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." />} {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…"> <LoadingFrame loading={loading || !draft} label="Loading campaign draft…">

View File

@@ -81,8 +81,8 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError}>{localError}</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." />} {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…"> <LoadingFrame loading={loading || !draft} label="Loading campaign draft…">

View File

@@ -148,9 +148,9 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{sendMessage && <DismissibleAlert tone="info" resetKey={sendMessage}>{sendMessage}</DismissibleAlert>} {sendMessage && <DismissibleAlert tone="info" resetKey={sendMessage} floating>{sendMessage}</DismissibleAlert>}
{mockMessage && <DismissibleAlert tone="info" resetKey={mockMessage}>{mockMessage}</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." />} {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…"}> <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>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError}>{localError}</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." />} {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…"> <LoadingFrame loading={loading || !draft} label="Loading campaign draft…">

View File

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

View File

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

View File

@@ -117,8 +117,8 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
</div> </div>
<div className="save-state">{saveState}</div> <div className="save-state">{saveState}</div>
</div> </div>
{localError && <DismissibleAlert tone="danger" resetKey={localError}>{localError}</DismissibleAlert>} {localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{validationMessage && <DismissibleAlert tone="info" resetKey={validationMessage}>{validationMessage}</DismissibleAlert>} {validationMessage && <DismissibleAlert tone="info" resetKey={validationMessage} floating>{validationMessage}</DismissibleAlert>}
<Card> <Card>
{draft && activeStep === "basics" && <BasicsStep draft={draft} patch={patch} />} {draft && activeStep === "basics" && <BasicsStep draft={draft} patch={patch} />}
{draft && activeStep === "sender" && <SenderStep 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" ? "↑" : "↓"}`; return `${label} ${sortDirection === "asc" ? "↑" : "↓"}`;
} }
const noticeTone = message.startsWith("No files") ? "warning" : message.endsWith("…") ? "info" : "success";
const toolbar = ( const toolbar = (
<div className="file-manager-toolbar" aria-label="File actions"> <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> <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 && ( {error && (
<DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert> <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" : ""}`}> <div className={`file-manager-shell ${busy ? "is-loading" : ""}`}>
<aside className="file-tree-panel" aria-label="File spaces and folders"> <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 ToggleSwitch from "../../components/ToggleSwitch";
import { apiFetch } from "../../api/client"; import { apiFetch } from "../../api/client";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav"; import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
import DismissibleAlert from "../../components/DismissibleAlert";
type SettingsSection = "interface" | "workspace" | "local-connection" | "notifications"; type SettingsSection = "interface" | "workspace" | "local-connection" | "notifications";
@@ -134,7 +135,7 @@ export default function SettingsPage({ settings, onSettingsChange }: { settings:
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button variant="primary" onClick={testConnection} disabled={testing}>{testing ? "Testing…" : "Test connection"}</Button> <Button variant="primary" onClick={testConnection} disabled={testing}>{testing ? "Testing…" : "Test connection"}</Button>
</div> </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> </div>
</Card> </Card>
<Card title="Session state"> <Card title="Session state">

View File

@@ -693,9 +693,7 @@
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
position: sticky; position: relative;
top: 10px;
z-index: 30;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08); box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
} }
@@ -704,17 +702,27 @@
flex: 1 1 auto; flex: 1 1 auto;
} }
.alert-floating { .alert-floating-stack {
position: fixed; position: fixed;
top: 131px; top: 131px;
left: 50%; left: 50%;
z-index: 1200; z-index: 1200;
display: grid;
gap: 10px;
width: min(640px, calc(100vw - 32px)); width: min(640px, calc(100vw - 32px));
margin: 0;
transform: translateX(-50%); 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: 1px solid rgba(15, 23, 42, 0.14);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.2); box-shadow: 0 16px 38px rgba(15, 23, 42, 0.2);
pointer-events: auto;
} }
.alert-dismiss { .alert-dismiss {