Dismissable alert use, DataGrid rework
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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>(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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…">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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…">
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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…">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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…">
|
||||
|
||||
@@ -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…">
|
||||
|
||||
@@ -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…"}>
|
||||
|
||||
@@ -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…">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user