chore: consolidate platform split checks
This commit is contained in:
@@ -39,22 +39,22 @@ export function ResourceAccessBoundary({ probe, resetKey, fallback, children }:
|
||||
let cancelled = false;
|
||||
setState("checking");
|
||||
setMessage("");
|
||||
probe()
|
||||
.then(() => { if (!cancelled) setState("allowed"); })
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
if (isApiError(error, 403, 404)) {
|
||||
setState("denied");
|
||||
return;
|
||||
}
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
setState("error");
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
probe().
|
||||
then(() => {if (!cancelled) setState("allowed");}).
|
||||
catch((error) => {
|
||||
if (cancelled) return;
|
||||
if (isApiError(error, 403, 404)) {
|
||||
setState("denied");
|
||||
return;
|
||||
}
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
setState("error");
|
||||
});
|
||||
return () => {cancelled = true;};
|
||||
}, [probe, resetKey]);
|
||||
|
||||
if (state === "denied") return <Navigate to={fallback} replace />;
|
||||
if (state === "error") return <div className="content-pad"><DismissibleAlert tone="danger" dismissible={false}>{message || "The resource could not be loaded."}</DismissibleAlert></div>;
|
||||
if (state === "checking") return <div className="content-pad"><p className="muted">Checking access…</p></div>;
|
||||
if (state === "error") return <div className="content-pad"><DismissibleAlert tone="danger" dismissible={false}>{message || "i18n:govoplan-core.the_resource_could_not_be_loaded.0d1b6cbf"}</DismissibleAlert></div>;
|
||||
if (state === "checking") return <div className="content-pad"><p className="muted">i18n:govoplan-core.checking_access.c5108c02</p></div>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
}
|
||||
64
webui/src/components/ActionBlockerHint.tsx
Normal file
64
webui/src/components/ActionBlockerHint.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import AdvancedOptionsPanel from "./AdvancedOptionsPanel";
|
||||
|
||||
export type ActionBlockerReason = {
|
||||
summary: ReactNode;
|
||||
details?: ReactNode;
|
||||
requiredAction?: ReactNode;
|
||||
actor?: ReactNode;
|
||||
target?: ReactNode;
|
||||
technicalDetails?: ReactNode;
|
||||
};
|
||||
|
||||
type ActionBlockerHintProps = {
|
||||
reason: ActionBlockerReason;
|
||||
tone?: "info" | "warning" | "danger";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function joinClasses(...classes: Array<string | undefined | false>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function ActionBlockerHint({ reason, tone = "warning", className = "" }: ActionBlockerHintProps) {
|
||||
const Icon = tone === "info" ? Info : AlertTriangle;
|
||||
const hasActionRows = Boolean(reason.requiredAction || reason.actor || reason.target);
|
||||
|
||||
return (
|
||||
<section className={joinClasses("action-blocker-hint", `tone-${tone}`, className)}>
|
||||
<Icon className="action-blocker-icon" size={18} aria-hidden="true" />
|
||||
<div className="action-blocker-copy">
|
||||
<strong>{reason.summary}</strong>
|
||||
{reason.details && <p>{reason.details}</p>}
|
||||
{hasActionRows && (
|
||||
<dl>
|
||||
{reason.requiredAction && (
|
||||
<>
|
||||
<dt>Required action</dt>
|
||||
<dd>{reason.requiredAction}</dd>
|
||||
</>
|
||||
)}
|
||||
{reason.actor && (
|
||||
<>
|
||||
<dt>Who can fix it</dt>
|
||||
<dd>{reason.actor}</dd>
|
||||
</>
|
||||
)}
|
||||
{reason.target && (
|
||||
<>
|
||||
<dt>Where to go</dt>
|
||||
<dd>{reason.target}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
{reason.technicalDetails && (
|
||||
<AdvancedOptionsPanel title="Technical details" className="action-blocker-technical">
|
||||
<div>{reason.technicalDetails}</div>
|
||||
</AdvancedOptionsPanel>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
33
webui/src/components/AdvancedOptionsPanel.tsx
Normal file
33
webui/src/components/AdvancedOptionsPanel.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type AdvancedOptionsPanelProps = {
|
||||
title?: ReactNode;
|
||||
summary?: ReactNode;
|
||||
children: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function joinClasses(...classes: Array<string | undefined | false>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function AdvancedOptionsPanel({
|
||||
title = "Advanced options",
|
||||
summary,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
className = ""
|
||||
}: AdvancedOptionsPanelProps) {
|
||||
return (
|
||||
<details className={joinClasses("advanced-options-panel", className)} open={defaultOpen}>
|
||||
<summary>
|
||||
<span>{title}</span>
|
||||
<ChevronDown size={16} aria-hidden="true" />
|
||||
</summary>
|
||||
{summary && <p className="advanced-options-summary">{summary}</p>}
|
||||
<div className="advanced-options-body">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
type CardProps = {
|
||||
title?: ReactNode;
|
||||
@@ -33,17 +34,19 @@ function writeCollapseState(storageKey: string | null, collapsed: boolean): void
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, collapsed ? "1" : "0");
|
||||
} catch {
|
||||
|
||||
// localStorage may be unavailable in private or restricted contexts.
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
export default function Card({ title, children, actions, collapsible = false, collapseKey, persistCollapse = true }: CardProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const storageKey = resolveCollapseStorageKey(collapsible, persistCollapse, collapseKey, title);
|
||||
const [collapseState, setCollapseState] = useState(() => ({ storageKey, collapsed: readCollapseState(storageKey) }));
|
||||
const collapsed = collapseState.storageKey === storageKey ? collapseState.collapsed : readCollapseState(storageKey);
|
||||
const hasHeader = Boolean(title || actions || collapsible);
|
||||
const body = <div className="card-body">{children}</div>;
|
||||
const shouldRenderBody = !collapsible || !collapsed;
|
||||
const collapseLabel = translateText(collapsed ? "i18n:govoplan-core.show_content.0528d8d2" : "i18n:govoplan-core.show_header_only.24afefca");
|
||||
|
||||
useEffect(() => {
|
||||
setCollapseState({ storageKey, collapsed: readCollapseState(storageKey) });
|
||||
@@ -57,29 +60,29 @@ export default function Card({ title, children, actions, collapsible = false, co
|
||||
|
||||
return (
|
||||
<section className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`}>
|
||||
{hasHeader && (
|
||||
<header className="card-header">
|
||||
{title && (typeof title === "string" ? <h2>{title}</h2> : <div className="card-title-node">{title}</div>)}
|
||||
{(actions || collapsible) && (
|
||||
<div className="card-actions">
|
||||
{hasHeader &&
|
||||
<header className="card-header">
|
||||
{title && (typeof title === "string" ? <h2>{translateText(title)}</h2> : <div className="card-title-node">{title}</div>)}
|
||||
{(actions || collapsible) &&
|
||||
<div className="card-actions">
|
||||
{actions}
|
||||
{collapsible && (
|
||||
<button
|
||||
type="button"
|
||||
className="card-collapse-toggle"
|
||||
aria-label={collapsed ? "Show content" : "Show header only"}
|
||||
aria-expanded={!collapsed}
|
||||
title={collapsed ? "Show content" : "Show header only"}
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
{collapsible &&
|
||||
<button
|
||||
type="button"
|
||||
className="card-collapse-toggle"
|
||||
aria-label={collapseLabel}
|
||||
aria-expanded={!collapsed}
|
||||
title={collapseLabel}
|
||||
onClick={toggleCollapsed}>
|
||||
|
||||
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</header>
|
||||
)}
|
||||
}
|
||||
{shouldRenderBody && (collapsible ? <div className="card-collapse-region">{body}</div> : body)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
</section>);
|
||||
|
||||
}
|
||||
105
webui/src/components/ColorPickerField.tsx
Normal file
105
webui/src/components/ColorPickerField.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { i18nMessage } from "../i18n/LanguageContext";import { Palette } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type InputHTMLAttributes } from "react";
|
||||
|
||||
type ColorPickerFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
swatches?: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_SWATCHES = [
|
||||
"#4f8cff", "#38a169", "#d69e2e", "#e53e3e", "#805ad5", "#319795",
|
||||
"#dd6b20", "#718096", "#1f2937", "#f59e0b", "#10b981", "#ef4444"];
|
||||
|
||||
|
||||
const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
function normalizeColor(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (/^[0-9a-fA-F]{6}$/.test(trimmed)) return `#${trimmed}`;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function useOutsideClose(open: boolean, onClose: () => void) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!ref.current || ref.current.contains(event.target as Node)) return;
|
||||
onClose();
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
return ref;
|
||||
}
|
||||
|
||||
export default function ColorPickerField({
|
||||
value,
|
||||
onChange,
|
||||
swatches = DEFAULT_SWATCHES,
|
||||
disabled,
|
||||
className = "",
|
||||
placeholder = "#4f8cff",
|
||||
...props
|
||||
}: ColorPickerFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useOutsideClose(open, () => setOpen(false));
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const normalizedValue = normalizeColor(value || "");
|
||||
const previewColor = HEX_PATTERN.test(normalizedValue) ? normalizedValue : "transparent";
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.setCustomValidity(normalizedValue && !HEX_PATTERN.test(normalizedValue) ? "i18n:govoplan-core.use_a_six_digit_hex_color_for_example_4f8cff.d74f4ad2" : "");
|
||||
}, [normalizedValue]);
|
||||
|
||||
function selectColor(nextColor: string) {
|
||||
onChange(nextColor);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`color-picker-field ${className}`.trim()}>
|
||||
<span className="color-picker-preview" style={{ backgroundColor: previewColor }} aria-hidden="true" />
|
||||
<input
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="text"
|
||||
pattern="#[0-9a-fA-F]{6}"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(normalizeColor(event.target.value))} />
|
||||
|
||||
<button type="button" className="color-picker-trigger" onClick={() => !disabled && setOpen((current) => !current)} disabled={disabled} aria-label="i18n:govoplan-core.choose_color.09b969bb">
|
||||
<Palette size={16} />
|
||||
</button>
|
||||
{open &&
|
||||
<div className="color-picker-popover" role="dialog" aria-label="i18n:govoplan-core.color_picker.91dee962">
|
||||
<div className="color-picker-grid">
|
||||
{swatches.map((swatch) =>
|
||||
<button
|
||||
key={swatch}
|
||||
type="button"
|
||||
className={swatch.toLowerCase() === normalizedValue.toLowerCase() ? "is-selected" : ""}
|
||||
style={{ backgroundColor: swatch }}
|
||||
onClick={() => selectColor(swatch)}
|
||||
aria-label={i18nMessage("i18n:govoplan-core.use_value.bac38fc3", { value0: swatch })} />
|
||||
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Button from "./Button";
|
||||
import Dialog from "./Dialog";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export type ConfirmDialogTone = "default" | "danger";
|
||||
|
||||
@@ -19,13 +20,14 @@ export default function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
confirmLabel = "i18n:govoplan-core.confirm.04a21221",
|
||||
cancelLabel = "i18n:govoplan-core.cancel.77dfd213",
|
||||
tone = "default",
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: ConfirmDialogProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -37,14 +39,14 @@ export default function ConfirmDialog({
|
||||
closeDisabled={busy}
|
||||
showCloseButton
|
||||
onClose={onCancel}
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
|
||||
<Button variant={tone === "danger" ? "danger" : "primary"} onClick={onConfirm} disabled={busy}>{busy ? "Working…" : confirmLabel}</Button>
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={busy}>{translateText(cancelLabel)}</Button>
|
||||
<Button variant={tone === "danger" ? "danger" : "primary"} onClick={onConfirm} disabled={busy}>{busy ? translateText("i18n:govoplan-core.working.13b7bfca") : translateText(confirmLabel)}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p>{message}</p>
|
||||
</Dialog>
|
||||
);
|
||||
}>
|
||||
|
||||
<p>{translateText(message)}</p>
|
||||
</Dialog>);
|
||||
|
||||
}
|
||||
|
||||
84
webui/src/components/ConnectionTree.tsx
Normal file
84
webui/src/components/ConnectionTree.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
export type ConnectionTreeColumn<T> = {
|
||||
id: string;
|
||||
header: ReactNode;
|
||||
width?: string;
|
||||
align?: "left" | "center" | "right";
|
||||
render: (row: T, depth: number) => ReactNode;
|
||||
};
|
||||
|
||||
export type ConnectionTreeProps<T> = {
|
||||
rows: T[];
|
||||
columns: ConnectionTreeColumn<T>[];
|
||||
getRowKey: (row: T) => string;
|
||||
getChildren?: (row: T) => T[];
|
||||
renderActions?: (row: T, depth: number) => ReactNode;
|
||||
emptyText?: ReactNode;
|
||||
className?: string;
|
||||
rowClassName?: (row: T, depth: number) => string;
|
||||
};
|
||||
|
||||
type FlatRow<T> = {
|
||||
row: T;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
export default function ConnectionTree<T>({
|
||||
rows,
|
||||
columns,
|
||||
getRowKey,
|
||||
getChildren,
|
||||
renderActions,
|
||||
emptyText = "i18n:govoplan-core.no_entries_configured.48e7b97c",
|
||||
className = "",
|
||||
rowClassName
|
||||
}: ConnectionTreeProps<T>) {
|
||||
const flatRows = flattenRows(rows, getChildren);
|
||||
const gridTemplateColumns = `${columns.map((column) => column.width || "minmax(0, 1fr)").join(" ")} ${renderActions ? "var(--connection-tree-actions-column, 196px)" : ""}`.trim();
|
||||
|
||||
return (
|
||||
<div className={`connection-tree ${className}`.trim()}>
|
||||
<div className="connection-tree-header" style={{ gridTemplateColumns }}>
|
||||
{columns.map((column) =>
|
||||
<div className={`connection-tree-cell ${column.align ? `align-${column.align}` : ""}`.trim()} key={column.id}>
|
||||
{column.header}
|
||||
</div>
|
||||
)}
|
||||
{renderActions && <div className="connection-tree-cell align-right">i18n:govoplan-core.actions.c3cd636a</div>}
|
||||
</div>
|
||||
{flatRows.length === 0 && <div className="connection-tree-empty">{emptyText}</div>}
|
||||
{flatRows.map(({ row, depth }) => {
|
||||
const className = ["connection-tree-row", depth > 0 ? "is-child" : "is-parent", rowClassName?.(row, depth) || ""].filter(Boolean).join(" ");
|
||||
return (
|
||||
<div className={className} style={{ gridTemplateColumns }} key={getRowKey(row)}>
|
||||
{columns.map((column, index) =>
|
||||
<div
|
||||
className={`connection-tree-cell ${column.align ? `align-${column.align}` : ""} ${index === 0 ? "connection-tree-primary-cell" : ""}`.trim()}
|
||||
key={column.id}>
|
||||
{index === 0 ?
|
||||
<div className="connection-tree-primary-content" style={{ "--connection-tree-depth": String(depth) } as CSSProperties}>
|
||||
{column.render(row, depth)}
|
||||
</div> :
|
||||
column.render(row, depth)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{renderActions && <div className="connection-tree-cell align-right connection-tree-actions">{renderActions(row, depth)}</div>}
|
||||
</div>);
|
||||
|
||||
})}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function flattenRows<T>(rows: T[], getChildren?: (row: T) => T[]): FlatRow<T>[] {
|
||||
const result: FlatRow<T>[] = [];
|
||||
for (const row of rows) {
|
||||
result.push({ row, depth: 0 });
|
||||
for (const child of getChildren?.(row) ?? []) {
|
||||
result.push({ row: child, depth: 1 });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
231
webui/src/components/DateTimeField.tsx
Normal file
231
webui/src/components/DateTimeField.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Clock } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type InputHTMLAttributes } from "react";
|
||||
|
||||
type BaseProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "min" | "max"> & {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
min?: string;
|
||||
max?: string;
|
||||
};
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const TIME_PATTERN = /^\d{2}:\d{2}$/;
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
function dateString(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
function parseDate(value: string): Date | null {
|
||||
if (!DATE_PATTERN.test(value)) return null;
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null;
|
||||
return date;
|
||||
}
|
||||
|
||||
function monthLabel(date: Date): string {
|
||||
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
function clampDateValue(value: string, min?: string, max?: string): string {
|
||||
if (min && value < min) return min;
|
||||
if (max && value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
function datePartsFromDateTime(value: string): {date: string;time: string;} {
|
||||
if (!value) return { date: "", time: "" };
|
||||
const [date = "", time = ""] = value.split("T", 2);
|
||||
return { date, time: time.slice(0, 5) };
|
||||
}
|
||||
|
||||
function combineDateTime(date: string, time: string): string {
|
||||
if (!date && !time) return "";
|
||||
return `${date || dateString(new Date())}T${time || "00:00"}`;
|
||||
}
|
||||
|
||||
function useOutsideClose(open: boolean, onClose: () => void) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!ref.current || ref.current.contains(event.target as Node)) return;
|
||||
onClose();
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
return ref;
|
||||
}
|
||||
|
||||
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", ...props }: BaseProps) {
|
||||
const selectedDate = parseDate(value);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date());
|
||||
const rootRef = useOutsideClose(open, () => setOpen(false));
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate) setVisibleMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
let message = "";
|
||||
if (value && !parseDate(value)) message = "i18n:govoplan-core.use_yyyy_mm_dd.406d2e4d";else
|
||||
if (value && min && value < min) message = `Use ${min} or later.`;else
|
||||
if (value && max && value > max) message = `Use ${max} or earlier.`;
|
||||
input.setCustomValidity(message);
|
||||
}, [value, min, max]);
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const first = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), 1);
|
||||
const start = new Date(first);
|
||||
const weekday = (first.getDay() + 6) % 7;
|
||||
start.setDate(first.getDate() - weekday);
|
||||
return Array.from({ length: 42 }, (_unused, index) => {
|
||||
const date = new Date(start);
|
||||
date.setDate(start.getDate() + index);
|
||||
return date;
|
||||
});
|
||||
}, [visibleMonth]);
|
||||
|
||||
function selectDate(nextDate: Date) {
|
||||
const nextValue = clampDateValue(dateString(nextDate), min, max);
|
||||
onChange(nextValue);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function moveMonth(delta: number) {
|
||||
setVisibleMonth((current) => new Date(current.getFullYear(), current.getMonth() + delta, 1));
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`date-field ${className}`.trim()}>
|
||||
<input
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="\d{4}-\d{2}-\d{2}"
|
||||
value={value}
|
||||
min={undefined}
|
||||
max={undefined}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)} />
|
||||
|
||||
<button type="button" className="date-field-trigger" onClick={() => !disabled && setOpen((current) => !current)} disabled={disabled} aria-label="i18n:govoplan-core.choose_date.e7877fd9">
|
||||
<CalendarDays size={16} />
|
||||
</button>
|
||||
{open &&
|
||||
<div className="date-field-popover" role="dialog" aria-label="i18n:govoplan-core.calendar_picker.18756418">
|
||||
<div className="date-field-monthbar">
|
||||
<button type="button" onClick={() => moveMonth(-1)} aria-label="i18n:govoplan-core.previous_month.46a29921"><ChevronLeft size={16} /></button>
|
||||
<strong>{monthLabel(visibleMonth)}</strong>
|
||||
<button type="button" onClick={() => moveMonth(1)} aria-label="i18n:govoplan-core.next_month.8abf7cf1"><ChevronRight size={16} /></button>
|
||||
</div>
|
||||
<div className="date-field-weekdays" aria-hidden="true">
|
||||
{["i18n:govoplan-core.mo.91e885d8", "i18n:govoplan-core.tu.b44892b7", "i18n:govoplan-core.we.a24ae9fa", "i18n:govoplan-core.th.6929e765", "i18n:govoplan-core.fr.aa77f314", "i18n:govoplan-core.sa.50cf95ce", "i18n:govoplan-core.su.25bfc51c"].map((label) => <span key={label}>{label}</span>)}
|
||||
</div>
|
||||
<div className="date-field-grid">
|
||||
{weeks.map((day) => {
|
||||
const dayValue = dateString(day);
|
||||
const inMonth = day.getMonth() === visibleMonth.getMonth();
|
||||
const isSelected = dayValue === value;
|
||||
const unavailable = Boolean(min && dayValue < min || max && dayValue > max);
|
||||
return (
|
||||
<button
|
||||
key={dayValue}
|
||||
type="button"
|
||||
className={[inMonth ? "" : "is-muted", isSelected ? "is-selected" : ""].filter(Boolean).join(" ")}
|
||||
onClick={() => selectDate(day)}
|
||||
disabled={unavailable}>
|
||||
|
||||
{day.getDate()}
|
||||
</button>);
|
||||
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", ...props }: BaseProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
let message = "";
|
||||
if (value && !TIME_PATTERN.test(value)) message = "i18n:govoplan-core.use_hh_mm.e995be3f";else
|
||||
if (value && min && value < min) message = `Use ${min} or later.`;else
|
||||
if (value && max && value > max) message = `Use ${max} or earlier.`;
|
||||
input.setCustomValidity(message);
|
||||
}, [value, min, max]);
|
||||
|
||||
return (
|
||||
<div className={`time-field ${className}`.trim()}>
|
||||
<input
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="\d{2}:\d{2}"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)} />
|
||||
|
||||
<Clock className="time-field-icon" size={16} aria-hidden="true" />
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function DateTimeField({ value, onChange, min, max, disabled, className = "", ...props }: BaseProps) {
|
||||
const parts = datePartsFromDateTime(value);
|
||||
const minParts = datePartsFromDateTime(min || "");
|
||||
const maxParts = datePartsFromDateTime(max || "");
|
||||
|
||||
function changeDate(nextDate: string) {
|
||||
onChange(combineDateTime(nextDate, parts.time));
|
||||
}
|
||||
|
||||
function changeTime(nextTime: string) {
|
||||
onChange(combineDateTime(parts.date, nextTime));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`date-time-field ${className}`.trim()}>
|
||||
<DateField
|
||||
{...props}
|
||||
value={parts.date}
|
||||
min={minParts.date}
|
||||
max={maxParts.date}
|
||||
disabled={disabled}
|
||||
onChange={changeDate} />
|
||||
|
||||
<TimeField
|
||||
value={parts.time}
|
||||
min={minParts.date && minParts.date === parts.date ? minParts.time : undefined}
|
||||
max={maxParts.date && maxParts.date === parts.date ? maxParts.time : undefined}
|
||||
disabled={disabled}
|
||||
onChange={changeTime} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export default DateField;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useId, type ReactNode } from "react";
|
||||
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export type DialogProps = {
|
||||
open: boolean;
|
||||
@@ -31,7 +32,7 @@ export default function Dialog({
|
||||
footer,
|
||||
role = "dialog",
|
||||
ariaDescribedBy,
|
||||
closeLabel = "Close",
|
||||
closeLabel = "i18n:govoplan-core.close.bbfa773e",
|
||||
closeOnBackdrop = true,
|
||||
showCloseButton = true,
|
||||
closeDisabled = false,
|
||||
@@ -45,6 +46,11 @@ export default function Dialog({
|
||||
}: DialogProps) {
|
||||
const titleId = useId();
|
||||
const canClose = Boolean(onClose) && !closeDisabled;
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const renderedTitle = translateReactNode(title, translateText);
|
||||
const renderedChildren = translateReactNode(children, translateText);
|
||||
const renderedFooter = translateReactNode(footer, translateText);
|
||||
const translatedCloseLabel = translateText(closeLabel);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canClose) return undefined;
|
||||
@@ -75,21 +81,21 @@ export default function Dialog({
|
||||
aria-describedby={ariaDescribedBy}
|
||||
>
|
||||
<div className={joinClasses("dialog-header", headerClassName)}>
|
||||
<h2 id={titleId} className={joinClasses("dialog-title", titleClassName)}>{title}</h2>
|
||||
<h2 id={titleId} className={joinClasses("dialog-title", titleClassName)}>{renderedTitle}</h2>
|
||||
{showCloseButton && onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="dialog-close"
|
||||
onClick={onClose}
|
||||
aria-label={closeLabel}
|
||||
aria-label={translatedCloseLabel}
|
||||
disabled={closeDisabled}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={joinClasses("dialog-body", bodyClassName)}>{children}</div>
|
||||
{footer && <div className={joinClasses("dialog-footer", footerClassName)}>{footer}</div>}
|
||||
<div className={joinClasses("dialog-body", bodyClassName)}>{renderedChildren}</div>
|
||||
{footer && <div className={joinClasses("dialog-footer", footerClassName)}>{renderedFooter}</div>}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
type AlertTone = "success" | "info" | "warning" | "danger";
|
||||
|
||||
@@ -17,20 +18,24 @@ type DismissibleAlertProps = {
|
||||
|
||||
let floatingAlertRoot: HTMLElement | null = null;
|
||||
|
||||
function getFloatingAlertRoot(): HTMLElement | null {
|
||||
function getFloatingAlertRoot(ariaLabel: string): HTMLElement | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
if (floatingAlertRoot?.isConnected) return floatingAlertRoot;
|
||||
if (floatingAlertRoot?.isConnected) {
|
||||
floatingAlertRoot.setAttribute("aria-label", ariaLabel);
|
||||
return floatingAlertRoot;
|
||||
}
|
||||
|
||||
const existing = document.getElementById("app-floating-alerts");
|
||||
if (existing) {
|
||||
floatingAlertRoot = existing;
|
||||
floatingAlertRoot.setAttribute("aria-label", ariaLabel);
|
||||
return floatingAlertRoot;
|
||||
}
|
||||
|
||||
floatingAlertRoot = document.createElement("div");
|
||||
floatingAlertRoot.id = "app-floating-alerts";
|
||||
floatingAlertRoot.className = "alert-floating-stack";
|
||||
floatingAlertRoot.setAttribute("aria-label", "Application notices");
|
||||
floatingAlertRoot.setAttribute("aria-label", ariaLabel);
|
||||
document.body.appendChild(floatingAlertRoot);
|
||||
return floatingAlertRoot;
|
||||
}
|
||||
@@ -68,6 +73,7 @@ export default function DismissibleAlert({
|
||||
resetKey,
|
||||
dismissStorageKey
|
||||
}: DismissibleAlertProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const storageKey = resolveDismissStorageKey(dismissStorageKey, resetKey);
|
||||
const [alertState, setAlertState] = useState(() => ({ storageKey, visible: !readDismissed(storageKey) }));
|
||||
const visible = alertState.storageKey === storageKey ? alertState.visible : !readDismissed(storageKey);
|
||||
@@ -77,6 +83,8 @@ export default function DismissibleAlert({
|
||||
}, [storageKey, resetKey, children]);
|
||||
|
||||
if (!visible) return null;
|
||||
const renderedChildren = translateReactNode(children, translateText);
|
||||
const floatingLabel = translateText("i18n:govoplan-core.application_notices");
|
||||
|
||||
function dismiss() {
|
||||
writeDismissed(storageKey);
|
||||
@@ -90,9 +98,9 @@ export default function DismissibleAlert({
|
||||
role={role}
|
||||
aria-live={role === "alert" ? "assertive" : "polite"}
|
||||
>
|
||||
<div className="alert-message">{children}</div>
|
||||
<div className="alert-message">{renderedChildren}</div>
|
||||
{dismissible && (
|
||||
<button type="button" className="alert-dismiss" aria-label="Dismiss notice" onClick={dismiss}>
|
||||
<button type="button" className="alert-dismiss" aria-label={translateText("i18n:govoplan-core.dismiss_notice.ce08c9ad")} onClick={dismiss}>
|
||||
<X size={16} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
@@ -100,6 +108,6 @@ export default function DismissibleAlert({
|
||||
);
|
||||
|
||||
if (!floating) return alert;
|
||||
const root = getFloatingAlertRoot();
|
||||
const root = getFloatingAlertRoot(floatingLabel);
|
||||
return root ? createPortal(alert, root) : alert;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Folder, FolderOpen } from "lucide-react";
|
||||
import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
||||
import { i18nMessage } from "../i18n/LanguageContext";
|
||||
|
||||
export type ExplorerTreeNodeContext = {
|
||||
depth: number;
|
||||
@@ -9,7 +10,7 @@ export type ExplorerTreeNodeContext = {
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
type ExplorerTreeProps<T> = {
|
||||
export type ExplorerTreeProps<T> = {
|
||||
nodes: T[];
|
||||
getNodeId: (node: T) => string;
|
||||
getNodeLabel: (node: T) => string;
|
||||
@@ -21,6 +22,11 @@ type ExplorerTreeProps<T> = {
|
||||
disabled?: boolean;
|
||||
depth?: number;
|
||||
className?: string;
|
||||
childrenBaseClassName?: string;
|
||||
nodeContainerClassName?: string;
|
||||
nodeWrapBaseClassName?: string;
|
||||
toggleBaseClassName?: string;
|
||||
nodeButtonBaseClassName?: string;
|
||||
renderToggleIcon?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
||||
renderNodeContent?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
||||
getNodeWrapClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
|
||||
@@ -47,6 +53,11 @@ export default function ExplorerTree<T>({
|
||||
disabled = false,
|
||||
depth = 1,
|
||||
className = "",
|
||||
childrenBaseClassName = "explorer-tree-children file-tree-children",
|
||||
nodeContainerClassName = "",
|
||||
nodeWrapBaseClassName = "explorer-tree-node-wrap file-tree-node-wrap",
|
||||
toggleBaseClassName = "explorer-tree-toggle file-tree-toggle",
|
||||
nodeButtonBaseClassName = "explorer-tree-node file-tree-node",
|
||||
renderToggleIcon,
|
||||
renderNodeContent,
|
||||
getNodeWrapClassName,
|
||||
@@ -63,7 +74,7 @@ export default function ExplorerTree<T>({
|
||||
if (nodes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={["explorer-tree-children", "file-tree-children", className].filter(Boolean).join(" ")}>
|
||||
<div className={[childrenBaseClassName, className].filter(Boolean).join(" ")}>
|
||||
{nodes.map((node) => {
|
||||
const nodeId = getNodeId(node);
|
||||
const children = getNodeChildren(node);
|
||||
@@ -74,14 +85,13 @@ export default function ExplorerTree<T>({
|
||||
const draggable = getNodeDraggable?.(node, context) ?? false;
|
||||
|
||||
return (
|
||||
<div key={nodeId}>
|
||||
<div key={nodeId} className={nodeContainerClassName}>
|
||||
<div
|
||||
className={[
|
||||
"explorer-tree-node-wrap",
|
||||
"file-tree-node-wrap",
|
||||
active ? "is-active" : "",
|
||||
getNodeWrapClassName?.(node, context) ?? ""
|
||||
].filter(Boolean).join(" ")}
|
||||
nodeWrapBaseClassName,
|
||||
active ? "is-active" : "",
|
||||
getNodeWrapClassName?.(node, context) ?? ""].
|
||||
filter(Boolean).join(" ")}
|
||||
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(depth * 14, 56)}px` }}
|
||||
draggable={draggable && !disabled}
|
||||
onContextMenu={onContextMenu ? (event) => onContextMenu(event, node, context) : undefined}
|
||||
@@ -89,59 +99,64 @@ export default function ExplorerTree<T>({
|
||||
onDragEnd={draggable && onDragEnd ? (event) => onDragEnd(event, node, context) : undefined}
|
||||
onDragOver={onDragOver ? (event) => onDragOver(event, node, context) : undefined}
|
||||
onDragLeave={onDragLeave ? (event) => onDragLeave(event, node, context) : undefined}
|
||||
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}
|
||||
>
|
||||
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="explorer-tree-toggle file-tree-toggle"
|
||||
className={toggleBaseClassName}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (hasChildren) onToggle(node, context);
|
||||
}}
|
||||
disabled={disabled || !hasChildren}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} ${getNodeLabel(node)}`}
|
||||
aria-expanded={hasChildren ? expanded : undefined}
|
||||
>
|
||||
aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", { value0: expanded ? "i18n:govoplan-core.collapse.9cf188d3" : "i18n:govoplan-core.expand.9869e506", value1: getNodeLabel(node) })}
|
||||
aria-expanded={hasChildren ? expanded : undefined}>
|
||||
|
||||
{renderToggleIcon?.(node, context) ?? (expanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={["explorer-tree-node", "file-tree-node", getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
|
||||
className={[nodeButtonBaseClassName, getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
|
||||
onClick={() => onOpen(node, context)}
|
||||
disabled={disabled}
|
||||
>
|
||||
disabled={disabled}>
|
||||
|
||||
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && expanded && (
|
||||
<ExplorerTree
|
||||
nodes={children}
|
||||
getNodeId={getNodeId}
|
||||
getNodeLabel={getNodeLabel}
|
||||
getNodeChildren={getNodeChildren}
|
||||
activeId={activeId}
|
||||
expandedIds={expandedIds}
|
||||
onOpen={onOpen}
|
||||
onToggle={onToggle}
|
||||
disabled={disabled}
|
||||
depth={depth + 1}
|
||||
renderToggleIcon={renderToggleIcon}
|
||||
renderNodeContent={renderNodeContent}
|
||||
getNodeWrapClassName={getNodeWrapClassName}
|
||||
getNodeWrapStyle={getNodeWrapStyle}
|
||||
getNodeButtonClassName={getNodeButtonClassName}
|
||||
getNodeDraggable={getNodeDraggable}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{hasChildren && expanded &&
|
||||
<ExplorerTree
|
||||
nodes={children}
|
||||
getNodeId={getNodeId}
|
||||
getNodeLabel={getNodeLabel}
|
||||
getNodeChildren={getNodeChildren}
|
||||
activeId={activeId}
|
||||
expandedIds={expandedIds}
|
||||
onOpen={onOpen}
|
||||
onToggle={onToggle}
|
||||
disabled={disabled}
|
||||
depth={depth + 1}
|
||||
childrenBaseClassName={childrenBaseClassName}
|
||||
nodeContainerClassName={nodeContainerClassName}
|
||||
nodeWrapBaseClassName={nodeWrapBaseClassName}
|
||||
toggleBaseClassName={toggleBaseClassName}
|
||||
nodeButtonBaseClassName={nodeButtonBaseClassName}
|
||||
renderToggleIcon={renderToggleIcon}
|
||||
renderNodeContent={renderNodeContent}
|
||||
getNodeWrapClassName={getNodeWrapClassName}
|
||||
getNodeWrapStyle={getNodeWrapStyle}
|
||||
getNodeButtonClassName={getNodeButtonClassName}
|
||||
getNodeDraggable={getNodeDraggable}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop} />
|
||||
|
||||
}
|
||||
</div>);
|
||||
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { UploadCloud } from "lucide-react";
|
||||
|
||||
export type FileDropZoneProps = {
|
||||
@@ -23,13 +23,13 @@ export default function FileDropZone({
|
||||
disabled = false,
|
||||
busy = false,
|
||||
progress = null,
|
||||
label = "Drop files here",
|
||||
actionLabel = "or click to select files",
|
||||
busyLabel = "Uploading files",
|
||||
label = "i18n:govoplan-core.drop_files_here.77348907",
|
||||
actionLabel = "i18n:govoplan-core.or_click_to_select_files.91b05dc1",
|
||||
busyLabel = "i18n:govoplan-core.uploading_files.6536791d",
|
||||
progressLabel,
|
||||
note,
|
||||
className = "",
|
||||
inputLabel = "Drop files here or click to select files",
|
||||
inputLabel = "i18n:govoplan-core.drop_files_here_or_click_to_select_files.7eda8608",
|
||||
onFiles
|
||||
}: FileDropZoneProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -79,25 +79,25 @@ export default function FileDropZone({
|
||||
event.preventDefault();
|
||||
setDragActive(false);
|
||||
if (!interactionDisabled) void handleFiles(event.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
{showProgress ? (
|
||||
<span
|
||||
className={`file-drop-progress ${progressValue === null ? "is-indeterminate" : ""}`.trim()}
|
||||
role="progressbar"
|
||||
aria-label="Upload progress"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={roundedProgress ?? undefined}
|
||||
style={progressStyle}
|
||||
>
|
||||
<span>{roundedProgress === null ? "" : `${roundedProgress}%`}</span>
|
||||
</span>
|
||||
) : (
|
||||
<UploadCloud size={28} aria-hidden="true" />
|
||||
)}
|
||||
}}>
|
||||
|
||||
{showProgress ?
|
||||
<span
|
||||
className={`file-drop-progress ${progressValue === null ? "is-indeterminate" : ""}`.trim()}
|
||||
role="progressbar"
|
||||
aria-label="i18n:govoplan-core.upload_progress.927d75fe"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={roundedProgress ?? undefined}
|
||||
style={progressStyle}>
|
||||
|
||||
<span>{roundedProgress === null ? "" : i18nMessage("i18n:govoplan-core.value.8c5f2c0e", { value0: roundedProgress })}</span>
|
||||
</span> :
|
||||
|
||||
<UploadCloud size={28} aria-hidden="true" />
|
||||
}
|
||||
<strong>{showProgress ? busyLabel : label}</strong>
|
||||
<span>{showProgress ? progressLabel ?? (roundedProgress === null ? "Uploading…" : `${roundedProgress}% uploaded`) : actionLabel}</span>
|
||||
<span>{showProgress ? progressLabel ?? (roundedProgress === null ? "i18n:govoplan-core.uploading.d921a79a" : i18nMessage("i18n:govoplan-core.value_uploaded.2c4bcc60", { value0: roundedProgress })) : actionLabel}</span>
|
||||
{note && <span className="muted small-text">{note}</span>}
|
||||
</div>
|
||||
<input
|
||||
@@ -106,8 +106,8 @@ export default function FileDropZone({
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
hidden
|
||||
onChange={(event) => event.target.files && void handleFiles(event.target.files)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
onChange={(event) => event.target.files && void handleFiles(event.target.files)} />
|
||||
|
||||
</>);
|
||||
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import FieldLabel from "./help/FieldLabel";
|
||||
import { helpForFieldLabel } from "../utils/fieldHelp";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export default function FormField({ label, help, children }: { label: ReactNode; help?: ReactNode; children: ReactNode }) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const renderedLabel = typeof label === "string" ? translateText(label) : label;
|
||||
return (
|
||||
<label className="form-field">
|
||||
<FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)}>{label}</FieldLabel>
|
||||
<FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)}>{renderedLabel}</FieldLabel>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
53
webui/src/components/GuidedConfigDialog.tsx
Normal file
53
webui/src/components/GuidedConfigDialog.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { WizardStep } from "../types";
|
||||
import Dialog, { type DialogProps } from "./Dialog";
|
||||
import Stepper from "./Stepper";
|
||||
|
||||
export type GuidedConfigDialogProps = Omit<DialogProps, "children"> & {
|
||||
steps: WizardStep[];
|
||||
activeStep: string;
|
||||
onStepChange: (stepId: string) => void;
|
||||
intro?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function joinClasses(...classes: Array<string | undefined | false>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function GuidedConfigDialog({
|
||||
steps,
|
||||
activeStep,
|
||||
onStepChange,
|
||||
intro,
|
||||
children,
|
||||
className = "",
|
||||
bodyClassName = "",
|
||||
...dialogProps
|
||||
}: GuidedConfigDialogProps) {
|
||||
const active = steps.find((step) => step.id === activeStep) ?? steps[0];
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...dialogProps}
|
||||
className={joinClasses("guided-config-dialog", className)}
|
||||
bodyClassName={joinClasses("guided-config-dialog-body", bodyClassName)}
|
||||
>
|
||||
<div className="guided-config-shell">
|
||||
<aside className="guided-config-sidebar" aria-label="Setup steps">
|
||||
<Stepper steps={steps} activeStep={active?.id ?? activeStep} onSelect={onStepChange} />
|
||||
</aside>
|
||||
<section className="guided-config-content">
|
||||
{intro && <div className="guided-config-intro">{intro}</div>}
|
||||
{active && (
|
||||
<header className="guided-config-step-heading">
|
||||
<h3>{active.label}</h3>
|
||||
{active.description && <p>{active.description}</p>}
|
||||
</header>
|
||||
)}
|
||||
<div className="guided-config-step-body">{children}</div>
|
||||
</section>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
42
webui/src/components/GuidedReviewList.tsx
Normal file
42
webui/src/components/GuidedReviewList.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type GuidedReviewItem = {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
detail?: ReactNode;
|
||||
tone?: "neutral" | "success" | "warning" | "danger";
|
||||
};
|
||||
|
||||
type GuidedReviewListProps = {
|
||||
items: GuidedReviewItem[];
|
||||
emptyText?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function joinClasses(...classes: Array<string | undefined | false>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function GuidedReviewList({
|
||||
items,
|
||||
emptyText = "Nothing to review.",
|
||||
className = ""
|
||||
}: GuidedReviewListProps) {
|
||||
if (items.length === 0) {
|
||||
return <div className={joinClasses("guided-review-list", "is-empty", className)}>{emptyText}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className={joinClasses("guided-review-list", className)}>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className={joinClasses("guided-review-row", item.tone ? `tone-${item.tone}` : undefined)}>
|
||||
<dt>{item.label}</dt>
|
||||
<dd>
|
||||
<strong>{item.value}</strong>
|
||||
{item.detail && <span>{item.detail}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
type LoadingFrameProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -7,20 +8,22 @@ type LoadingFrameProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function LoadingFrame({ children, loading = false, label = "Loading data…", className = "" }: LoadingFrameProps) {
|
||||
export default function LoadingFrame({ children, loading = false, label = "i18n:govoplan-core.loading_data.089f19c5", className = "" }: LoadingFrameProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const translatedLabel = translateText(label);
|
||||
const classNames = ["loading-frame", loading ? "is-loading" : "", className].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div className={classNames} aria-busy={loading || undefined}>
|
||||
{children}
|
||||
{loading && (
|
||||
<div className="loading-frame-overlay" role="status" aria-live="polite">
|
||||
{loading &&
|
||||
<div className="loading-frame-overlay" role="status" aria-live="polite">
|
||||
<div className="loading-frame-panel">
|
||||
<LoadingIndicator label={label} size="md" />
|
||||
<span>{label}</span>
|
||||
<LoadingIndicator label={translatedLabel} size="md" />
|
||||
<span>{translatedLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
type LoadingIndicatorProps = {
|
||||
label?: string;
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
export default function LoadingIndicator({ label = "Loading", size = "sm" }: LoadingIndicatorProps) {
|
||||
export default function LoadingIndicator({ label = "i18n:govoplan-core.loading.8f26c652", size = "sm" }: LoadingIndicatorProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const translatedLabel = translateText(label);
|
||||
return (
|
||||
<span className={`loading-indicator loading-indicator-${size}`} role="status" aria-label={label} title={label}>
|
||||
<span className={`loading-indicator loading-indicator-${size}`} role="status" aria-label={translatedLabel} title={translatedLabel}>
|
||||
<span className="loading-envelope" aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
</span>);
|
||||
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Archive, LockKeyhole, Paperclip } from "lucide-react";
|
||||
import { i18nMessage, usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import SegmentedControl from "./SegmentedControl";
|
||||
|
||||
export type MessageDisplayField = {
|
||||
label: string;
|
||||
@@ -43,8 +45,9 @@ export default function MessageDisplayPanel({
|
||||
deriveTextFromHtml = true,
|
||||
headers = {},
|
||||
attachments = [],
|
||||
emptyText = "Select an item to inspect its content."
|
||||
emptyText = "i18n:govoplan-core.select_an_item_to_inspect_its_content.1f67f131"
|
||||
}: MessageDisplayPanelProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const visibleFields = fields.filter((field) => hasRenderableValue(field.value));
|
||||
const headerEntries = Object.entries(headers);
|
||||
const hasHtml = Boolean(bodyHtml?.trim());
|
||||
@@ -60,92 +63,99 @@ export default function MessageDisplayPanel({
|
||||
}, [defaultBodyMode, title, bodyText, bodyHtml, bodyPreview]);
|
||||
|
||||
if (!title && visibleFields.length === 0 && !hasText && !hasHtml && attachments.length === 0 && headerEntries.length === 0) {
|
||||
return <p className="muted">{emptyText}</p>;
|
||||
return <p className="muted">{translateText(emptyText)}</p>;
|
||||
}
|
||||
|
||||
const activeBodyMode = bodyMode === "html" && hasHtml ? "html" : "text";
|
||||
const showBodySwitch = hasHtml && hasText;
|
||||
const htmlBodyFallback = translateText("i18n:govoplan-core.p_no_html_body_content_p.c305bfc6");
|
||||
const textBodyFallback = translateText("i18n:govoplan-core.no_readable_body_content.37643a01");
|
||||
|
||||
return (
|
||||
<div className="message-display-panel">
|
||||
<div className="message-display-header">
|
||||
<h3>{title || "(no subject)"}</h3>
|
||||
{visibleFields.length > 0 && (
|
||||
<dl className="message-display-fields">
|
||||
{visibleFields.map((field) => (
|
||||
<div key={field.label}>
|
||||
<h3>{title || "i18n:govoplan-core.no_subject.49b20da0"}</h3>
|
||||
{visibleFields.length > 0 &&
|
||||
<dl className="message-display-fields">
|
||||
{visibleFields.map((field) =>
|
||||
<div key={field.label}>
|
||||
<dt>{field.label}</dt>
|
||||
<dd>{field.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
|
||||
<section className="message-display-body-section" aria-label="Message body">
|
||||
<section className="message-display-body-section" aria-label="i18n:govoplan-core.message_body.daee2627">
|
||||
<div className="message-display-section-heading">
|
||||
<h4>Message</h4>
|
||||
{showBodySwitch && (
|
||||
<div className="message-display-body-switch" role="tablist" aria-label="Message body format">
|
||||
<button type="button" className={activeBodyMode === "html" ? "active" : ""} onClick={() => setBodyMode("html")}>HTML</button>
|
||||
<button type="button" className={activeBodyMode === "text" ? "active" : ""} onClick={() => setBodyMode("text")}>Text</button>
|
||||
</div>
|
||||
)}
|
||||
<h4>i18n:govoplan-core.message.68f4145f</h4>
|
||||
{showBodySwitch &&
|
||||
<SegmentedControl
|
||||
ariaLabel="i18n:govoplan-core.message_body_format.5fec42d2"
|
||||
value={activeBodyMode}
|
||||
onChange={setBodyMode}
|
||||
options={[
|
||||
{ id: "html", label: "i18n:govoplan-core.html.9f738ce8" },
|
||||
{ id: "text", label: "i18n:govoplan-core.text.c3328c39" }
|
||||
]}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
{activeBodyMode === "html" ? (
|
||||
<iframe className="message-display-html-frame" title="Rendered HTML message body" sandbox="" srcDoc={bodyHtml || "<p>No HTML body content.</p>"} />
|
||||
) : (
|
||||
<pre className="message-display-body">{textBody || "No readable body content."}</pre>
|
||||
)}
|
||||
{activeBodyMode === "html" ?
|
||||
<iframe className="message-display-html-frame" title="i18n:govoplan-core.rendered_html_message_body.8638b634" sandbox="" srcDoc={bodyHtml || htmlBodyFallback} /> :
|
||||
|
||||
<pre className="message-display-body">{textBody || textBodyFallback}</pre>
|
||||
}
|
||||
</section>
|
||||
|
||||
{attachments.length ? (
|
||||
<div className="message-display-attachments">
|
||||
<h4>Attachments</h4>
|
||||
{attachments.length ?
|
||||
<div className="message-display-attachments">
|
||||
<h4>i18n:govoplan-core.attachments.6771ade6</h4>
|
||||
<div className="message-display-attachments-scroll">
|
||||
{groupedAttachments.direct.length > 0 && (
|
||||
<div className="message-display-attachment-list">
|
||||
{groupedAttachments.direct.length > 0 &&
|
||||
<div className="message-display-attachment-list">
|
||||
{groupedAttachments.direct.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
|
||||
</div>
|
||||
)}
|
||||
{groupedAttachments.archives.map((archive) => (
|
||||
<section className="message-display-attachment-archive" key={archive.key}>
|
||||
}
|
||||
{groupedAttachments.archives.map((archive) =>
|
||||
<section className="message-display-attachment-archive" key={archive.key}>
|
||||
<header>
|
||||
<div>
|
||||
<strong><Archive size={15} aria-hidden="true" /> {archive.label}</strong>
|
||||
<span>{archive.items.length} file{archive.items.length === 1 ? "" : "s"} inside ZIP</span>
|
||||
<span>{archive.items.length} file{archive.items.length === 1 ? "" : "s"} i18n:govoplan-core.inside_zip.68c3fd85</span>
|
||||
</div>
|
||||
{archive.protected && (
|
||||
<div className="message-display-attachment-protection">
|
||||
<small><LockKeyhole size={13} aria-hidden="true" /> Password protected</small>
|
||||
{archive.protected &&
|
||||
<div className="message-display-attachment-protection">
|
||||
<small><LockKeyhole size={13} aria-hidden="true" /> i18n:govoplan-core.password_protected.09d9e174</small>
|
||||
{archive.protectionNote && <small className="message-display-attachment-protection-note">{formatProtectionNote(archive.protectionNote)}</small>}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</header>
|
||||
<div className="message-display-attachment-list">
|
||||
{archive.items.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div> :
|
||||
null}
|
||||
|
||||
{headerEntries.length > 0 && (
|
||||
<details className="message-display-headers">
|
||||
<summary>Headers</summary>
|
||||
{headerEntries.length > 0 &&
|
||||
<details className="message-display-headers">
|
||||
<summary>i18n:govoplan-core.headers.520de744</summary>
|
||||
<dl>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
<div key={key}><dt>{key}</dt><dd>{value}</dd></div>
|
||||
))}
|
||||
{headerEntries.map(([key, value]) =>
|
||||
<div key={key}><dt>{key}</dt><dd>{value}</dd></div>
|
||||
)}
|
||||
</dl>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function AttachmentRow({ attachment, index }: { attachment: MessageDisplayAttachment; index: number }) {
|
||||
function AttachmentRow({ attachment, index }: {attachment: MessageDisplayAttachment;index: number;}) {
|
||||
const contentType = formatContentType(attachment.contentType);
|
||||
const size = formatBytes(attachment.sizeBytes);
|
||||
const hasMeta = Boolean(contentType || size);
|
||||
@@ -153,17 +163,17 @@ function AttachmentRow({ attachment, index }: { attachment: MessageDisplayAttach
|
||||
<div className="message-display-attachment-row">
|
||||
<Paperclip size={14} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{attachment.filename || `Attachment ${index + 1}`}</strong>
|
||||
<strong>{attachment.filename || i18nMessage("i18n:govoplan-core.attachment_value.01801a54", { value0: index + 1 })}</strong>
|
||||
{attachment.detail && <small className="message-display-attachment-detail">{attachment.detail}</small>}
|
||||
{hasMeta && (
|
||||
<small className="message-display-attachment-meta">
|
||||
{hasMeta &&
|
||||
<small className="message-display-attachment-meta">
|
||||
{contentType && <span title={contentType.full}>{contentType.label}</span>}
|
||||
{size && <span>{size}</span>}
|
||||
</small>
|
||||
)}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function groupAttachments(attachments: MessageDisplayAttachment[]) {
|
||||
@@ -198,45 +208,51 @@ function hasRenderableValue(value: ReactNode | null | undefined): boolean {
|
||||
}
|
||||
|
||||
function formatProtectionNote(value: string): string {
|
||||
return value
|
||||
.replace(/^Password-protected ZIP(?: using)?\s*/i, "")
|
||||
.replace(/^Password protected(?: using)?\s*/i, "")
|
||||
.replace(/^using\s+/i, "")
|
||||
.replace(/\.\s*Encryption:/i, " · Encryption:")
|
||||
.trim();
|
||||
const normalized = value.
|
||||
replace(/^Password-protected ZIP(?: using)?\s*/i, "").
|
||||
replace(/^Password protected(?: using)?\s*/i, "").
|
||||
replace(/^using\s+/i, "").
|
||||
trim();
|
||||
const encryptionMatch = normalized.match(/^(.*?)\.\s*Encryption:\s*(.*)$/i);
|
||||
if (!encryptionMatch) return normalized;
|
||||
const source = encryptionMatch[1].trim();
|
||||
const method = encryptionMatch[2].trim();
|
||||
return source ?
|
||||
i18nMessage("i18n:govoplan-core.value_encryption_value", { value0: source, value1: method }) :
|
||||
i18nMessage("i18n:govoplan-core.encryption_value", { value0: method });
|
||||
}
|
||||
|
||||
function formatContentType(value?: string | null): { label: string; full: string } | null {
|
||||
function formatContentType(value?: string | null): {label: string;full: string;} | null {
|
||||
const full = value?.trim();
|
||||
if (!full) return null;
|
||||
const normalized = full.toLowerCase();
|
||||
const known: Record<string, string> = {
|
||||
"application/pdf": "PDF",
|
||||
"application/zip": "ZIP archive",
|
||||
"application/x-zip-compressed": "ZIP archive",
|
||||
"application/octet-stream": "Binary file",
|
||||
"text/plain": "Plain text",
|
||||
"text/html": "HTML",
|
||||
"text/csv": "CSV",
|
||||
"application/json": "JSON",
|
||||
"image/jpeg": "JPEG image",
|
||||
"image/png": "PNG image",
|
||||
"image/gif": "GIF image",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "XLSX spreadsheet",
|
||||
"application/vnd.ms-excel": "Excel spreadsheet",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "DOCX document",
|
||||
"application/msword": "Word document",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "PPTX presentation",
|
||||
"application/vnd.ms-powerpoint": "PowerPoint presentation"
|
||||
"application/pdf": "i18n:govoplan-core.pdf.d613d88c",
|
||||
"application/zip": "i18n:govoplan-core.zip_archive.5a2430dd",
|
||||
"application/x-zip-compressed": "i18n:govoplan-core.zip_archive.5a2430dd",
|
||||
"application/octet-stream": "i18n:govoplan-core.binary_file.a4de1904",
|
||||
"text/plain": "i18n:govoplan-core.plain_text.9580fcbc",
|
||||
"text/html": "i18n:govoplan-core.html.9f738ce8",
|
||||
"text/csv": "i18n:govoplan-core.csv.32811883",
|
||||
"application/json": "i18n:govoplan-core.json.031a4e76",
|
||||
"image/jpeg": "i18n:govoplan-core.jpeg_image.6876b7ff",
|
||||
"image/png": "i18n:govoplan-core.png_image.6715d4b8",
|
||||
"image/gif": "i18n:govoplan-core.gif_image.d092a779",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9",
|
||||
"application/vnd.ms-excel": "i18n:govoplan-core.excel_spreadsheet.7107fea2",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "i18n:govoplan-core.docx_document.f0b09822",
|
||||
"application/msword": "i18n:govoplan-core.word_document.f593629d",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "i18n:govoplan-core.pptx_presentation.855d193b",
|
||||
"application/vnd.ms-powerpoint": "i18n:govoplan-core.powerpoint_presentation.b32b7115"
|
||||
};
|
||||
if (known[normalized]) return { label: known[normalized], full };
|
||||
const subtype = normalized.split("/")[1]?.split(";")[0] || normalized;
|
||||
const cleaned = subtype
|
||||
.replace(/^vnd\./, "")
|
||||
.replace(/^x-/, "")
|
||||
.replace(/openxmlformats-officedocument\./g, "")
|
||||
.replace(/[.+_-]+/g, " ")
|
||||
.trim();
|
||||
const cleaned = subtype.
|
||||
replace(/^vnd\./, "").
|
||||
replace(/^x-/, "").
|
||||
replace(/openxmlformats-officedocument\./g, "").
|
||||
replace(/[.+_-]+/g, " ").
|
||||
trim();
|
||||
const label = titleCase(cleaned || normalized);
|
||||
return { label: label.length > 34 ? `${label.slice(0, 31)}...` : label, full };
|
||||
}
|
||||
@@ -247,9 +263,9 @@ function titleCase(value: string): string {
|
||||
|
||||
function formatBytes(value?: number | null): string {
|
||||
if (!value) return "";
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (value < 1024) return i18nMessage("i18n:govoplan-core.bytes_b", { value0: value });
|
||||
if (value < 1024 * 1024) return i18nMessage("i18n:govoplan-core.bytes_kb", { value0: (value / 1024).toFixed(1) });
|
||||
return i18nMessage("i18n:govoplan-core.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
|
||||
}
|
||||
|
||||
function stripHtml(value: string): string {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
type PageTitleProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -6,10 +7,12 @@ type PageTitleProps = {
|
||||
};
|
||||
|
||||
export default function PageTitle({ children, loading = false }: PageTitleProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const renderedChildren = typeof children === "string" ? translateText(children) : children;
|
||||
return (
|
||||
<h1 className="page-title-with-loader">
|
||||
<span>{children}</span>
|
||||
{loading && <LoadingIndicator label="Loading page data" />}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
<span>{renderedChildren}</span>
|
||||
{loading && <LoadingIndicator label="i18n:govoplan-core.loading_page_data.85fe9edf" />}
|
||||
</h1>);
|
||||
|
||||
}
|
||||
@@ -16,8 +16,8 @@ export default function PasswordField({
|
||||
onValueChange,
|
||||
saved = false,
|
||||
savedPlaceholder = "••••••••",
|
||||
revealLabel = "Show password",
|
||||
hideLabel = "Hide password",
|
||||
revealLabel = "i18n:govoplan-core.show_password.044b852f",
|
||||
hideLabel = "i18n:govoplan-core.hide_password.e40123b4",
|
||||
placeholder,
|
||||
disabled = false,
|
||||
className = "",
|
||||
@@ -46,19 +46,19 @@ export default function PasswordField({
|
||||
onChange={(event) => {
|
||||
onValueChange(event.target.value);
|
||||
if (!event.target.value) setVisible(false);
|
||||
}}
|
||||
/>
|
||||
{canReveal && (
|
||||
<button
|
||||
type="button"
|
||||
className="password-field-toggle"
|
||||
aria-label={visible ? hideLabel : revealLabel}
|
||||
title={visible ? hideLabel : revealLabel}
|
||||
onClick={() => setVisible((current) => !current)}
|
||||
>
|
||||
}} />
|
||||
|
||||
{canReveal &&
|
||||
<button
|
||||
type="button"
|
||||
className="password-field-toggle"
|
||||
aria-label={visible ? hideLabel : revealLabel}
|
||||
title={visible ? hideLabel : revealLabel}
|
||||
onClick={() => setVisible((current) => !current)}>
|
||||
|
||||
{visible ? <EyeOff size={17} aria-hidden="true" /> : <Eye size={17} aria-hidden="true" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export type PolicySourcePathItem = string | {
|
||||
scope_type?: string;
|
||||
scope_id?: string | null;
|
||||
path?: string;
|
||||
label: string;
|
||||
applied_fields?: string[];
|
||||
appliedFields?: string[];
|
||||
@@ -15,10 +16,12 @@ export type PolicySourcePathProps = {
|
||||
|
||||
type NormalizedPolicySourcePathItem = {
|
||||
label: string;
|
||||
path?: string;
|
||||
appliedFields: string[];
|
||||
policy?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export default function PolicySourcePath({ items, label = "Source path", className = "" }: PolicySourcePathProps) {
|
||||
export default function PolicySourcePath({ items, label = "i18n:govoplan-core.source_path.5956aebe", className = "" }: PolicySourcePathProps) {
|
||||
const cleanItems = items.map(normalizeSourceItem).filter((item): item is NormalizedPolicySourcePathItem => Boolean(item?.label));
|
||||
if (cleanItems.length === 0) return null;
|
||||
|
||||
@@ -26,15 +29,15 @@ export default function PolicySourcePath({ items, label = "Source path", classNa
|
||||
<div className={`policy-source-path ${className}`.trim()} aria-label={label}>
|
||||
<span className="policy-source-path-label">{label}</span>
|
||||
<ol>
|
||||
{cleanItems.map((item, index) => (
|
||||
<li key={`${item.label}-${index}`}>
|
||||
<span>{item.label}</span>
|
||||
{item.appliedFields.length > 0 && <small>{sourceFieldSummary(item.appliedFields)}</small>}
|
||||
{cleanItems.map((item, index) =>
|
||||
<li key={`${item.label}-${index}`}>
|
||||
<span title={item.path}>{item.label}</span>
|
||||
{item.appliedFields.length > 0 && <small title={sourceFieldSummary(item.appliedFields, item.policy)}>{sourceFieldSummary(item.appliedFields, item.policy)}</small>}
|
||||
</li>
|
||||
))}
|
||||
)}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function normalizeSourceItem(item: PolicySourcePathItem): NormalizedPolicySourcePathItem | null {
|
||||
@@ -45,23 +48,46 @@ function normalizeSourceItem(item: PolicySourcePathItem): NormalizedPolicySource
|
||||
const label = item.label?.trim();
|
||||
if (!label) return null;
|
||||
const appliedFields = [...(item.applied_fields ?? item.appliedFields ?? [])].map((field) => field.trim()).filter(Boolean);
|
||||
return { label, appliedFields };
|
||||
return { label, path: item.path, appliedFields, policy: item.policy };
|
||||
}
|
||||
|
||||
function sourceFieldSummary(fields: string[]): string {
|
||||
function sourceFieldSummary(fields: string[], policy?: Record<string, unknown> | null): string {
|
||||
const unique = Array.from(new Set(fields));
|
||||
if (unique.length === 0) return "";
|
||||
if (unique.length === 1 && unique[0] === "defaults") return "defaults";
|
||||
if (unique.length <= 3) return unique.map(sourceFieldLabel).join(", ");
|
||||
if (unique.length <= 3) return unique.map((field) => sourceFieldDetail(field, policy)).join(", ");
|
||||
return `${unique.slice(0, 2).map(sourceFieldLabel).join(", ")} +${unique.length - 2}`;
|
||||
}
|
||||
|
||||
function sourceFieldDetail(field: string, policy?: Record<string, unknown> | null): string {
|
||||
const value = readPolicyField(policy, field);
|
||||
const label = sourceFieldLabel(field);
|
||||
if (value === undefined) return label;
|
||||
if (typeof value === "boolean") return `${label}: ${value ? "allow" : "deny"}`;
|
||||
if (typeof value === "number" || typeof value === "string") return `${label}: ${value}`;
|
||||
if (value === null) return `${label}: none`;
|
||||
if (Array.isArray(value)) return `${label}: ${value.length}`;
|
||||
if (typeof value === "object") {
|
||||
const locked = Object.values(value as Record<string, unknown>).filter((item) => item === false).length;
|
||||
return locked > 0 ? `${label}: ${locked} locked` : label;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
function readPolicyField(policy: Record<string, unknown> | null | undefined, field: string): unknown {
|
||||
if (!policy) return undefined;
|
||||
return field.split(".").reduce<unknown>((current, segment) => {
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) return undefined;
|
||||
return (current as Record<string, unknown>)[segment];
|
||||
}, policy);
|
||||
}
|
||||
|
||||
function sourceFieldLabel(field: string): string {
|
||||
if (field === "defaults") return "defaults";
|
||||
const clean = field
|
||||
.replace(/^whitelist[.]/, "allow ")
|
||||
.replace(/^blacklist[.]/, "block ")
|
||||
.replace(/[_.]/g, " ")
|
||||
.trim();
|
||||
const clean = field.
|
||||
replace(/^whitelist[.]/, "allow ").
|
||||
replace(/^blacklist[.]/, "block ").
|
||||
replace(/[_.]/g, " ").
|
||||
trim();
|
||||
return clean.charAt(0).toUpperCase() + clean.slice(1);
|
||||
}
|
||||
|
||||
131
webui/src/components/PolicyTable.tsx
Normal file
131
webui/src/components/PolicyTable.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { ReactNode } from "react";
|
||||
import FieldLabel from "./help/FieldLabel";
|
||||
|
||||
type PolicySectionProps = {
|
||||
title?: ReactNode;
|
||||
summary?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
headingClassName?: string;
|
||||
};
|
||||
|
||||
type PolicyTableProps = {
|
||||
children: ReactNode;
|
||||
fieldLabel: ReactNode;
|
||||
settingLabel: ReactNode;
|
||||
effectiveLabel?: ReactNode;
|
||||
lowerLevelLabel?: ReactNode;
|
||||
showEffectiveColumn?: boolean;
|
||||
showAllowColumn?: boolean;
|
||||
className?: string;
|
||||
rowClassName?: string;
|
||||
headerClassName?: string;
|
||||
};
|
||||
|
||||
type PolicyRowProps = {
|
||||
label: ReactNode;
|
||||
help?: ReactNode;
|
||||
note?: ReactNode;
|
||||
control: ReactNode;
|
||||
effective?: ReactNode;
|
||||
effectiveHelp?: ReactNode;
|
||||
allowControl?: ReactNode;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
controlClassName?: string;
|
||||
effectiveClassName?: string;
|
||||
effectiveCellClassName?: string;
|
||||
};
|
||||
|
||||
export function PolicySection({
|
||||
title,
|
||||
summary,
|
||||
actions,
|
||||
children,
|
||||
className = "",
|
||||
headingClassName = "subsection-heading split"
|
||||
}: PolicySectionProps) {
|
||||
return (
|
||||
<section className={["policy-section", className].filter(Boolean).join(" ")}>
|
||||
{(title || summary || actions) &&
|
||||
<div className={headingClassName}>
|
||||
{title && <h3>{title}</h3>}
|
||||
{actions ?? summary}
|
||||
</div>
|
||||
}
|
||||
{children}
|
||||
</section>);
|
||||
|
||||
}
|
||||
|
||||
export function PolicyTable({
|
||||
children,
|
||||
fieldLabel,
|
||||
settingLabel,
|
||||
effectiveLabel,
|
||||
lowerLevelLabel,
|
||||
showEffectiveColumn = false,
|
||||
showAllowColumn = false,
|
||||
className = "",
|
||||
rowClassName = "",
|
||||
headerClassName = ""
|
||||
}: PolicyTableProps) {
|
||||
return (
|
||||
<div className={[
|
||||
"policy-table",
|
||||
showEffectiveColumn ? "with-effective-column" : "",
|
||||
showAllowColumn ? "with-allow-column" : "",
|
||||
className].
|
||||
filter(Boolean).join(" ")}>
|
||||
<div className={[rowClassName, "policy-row", "policy-row-header", headerClassName].filter(Boolean).join(" ")}>
|
||||
<span>{fieldLabel}</span>
|
||||
<span>{settingLabel}</span>
|
||||
{showEffectiveColumn && <span>{effectiveLabel}</span>}
|
||||
{showAllowColumn && <span>{lowerLevelLabel}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function PolicyRow({
|
||||
label,
|
||||
help,
|
||||
note,
|
||||
control,
|
||||
effective,
|
||||
effectiveHelp,
|
||||
allowControl,
|
||||
className = "",
|
||||
labelClassName = "",
|
||||
controlClassName = "",
|
||||
effectiveClassName = "",
|
||||
effectiveCellClassName = ""
|
||||
}: PolicyRowProps) {
|
||||
return (
|
||||
<div className={["policy-row", className].filter(Boolean).join(" ")}>
|
||||
<div className={["policy-field-label", labelClassName].filter(Boolean).join(" ")}>
|
||||
{help ?
|
||||
<FieldLabel className="policy-field-title" help={help}>{label}</FieldLabel> :
|
||||
<strong>{label}</strong>
|
||||
}
|
||||
{note && (typeof note === "string" ? <small>{note}</small> : note)}
|
||||
</div>
|
||||
<div className={["policy-control", controlClassName].filter(Boolean).join(" ")}>{control}</div>
|
||||
{effective !== undefined &&
|
||||
<div className={["policy-effective-cell", effectiveCellClassName].filter(Boolean).join(" ")}>
|
||||
{effectiveHelp ?
|
||||
<FieldLabel className={["policy-effective-value", effectiveClassName].filter(Boolean).join(" ")} help={effectiveHelp}>
|
||||
{effective}
|
||||
</FieldLabel> :
|
||||
<span className={["policy-effective-value", effectiveClassName].filter(Boolean).join(" ")}>
|
||||
{effective}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
{allowControl}
|
||||
</div>);
|
||||
|
||||
}
|
||||
76
webui/src/components/SegmentedControl.tsx
Normal file
76
webui/src/components/SegmentedControl.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
export type SegmentedControlSize = "content" | "equal";
|
||||
export type SegmentedControlWidth = "inline" | "fill";
|
||||
|
||||
export type SegmentedControlOption<TValue extends string = string> = {
|
||||
id: TValue;
|
||||
label: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export type SegmentedControlProps<TValue extends string = string> = {
|
||||
options: Array<SegmentedControlOption<TValue>>;
|
||||
value: TValue;
|
||||
onChange: (value: TValue) => void;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledBy?: string;
|
||||
className?: string;
|
||||
optionClassName?: string;
|
||||
disabled?: boolean;
|
||||
role?: "group" | "tablist";
|
||||
size?: SegmentedControlSize;
|
||||
width?: SegmentedControlWidth;
|
||||
};
|
||||
|
||||
export default function SegmentedControl<TValue extends string = string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
className = "",
|
||||
optionClassName = "",
|
||||
disabled = false,
|
||||
role = "tablist",
|
||||
size = "content",
|
||||
width = "inline"
|
||||
}: SegmentedControlProps<TValue>) {
|
||||
const rootClassName = [
|
||||
"segmented-control",
|
||||
`segmented-control-size-${size}`,
|
||||
`segmented-control-width-${width}`,
|
||||
className
|
||||
].filter(Boolean).join(" ");
|
||||
const optionRole = role === "tablist" ? "tab" : undefined;
|
||||
|
||||
return (
|
||||
<div className={rootClassName} role={role} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy}>
|
||||
{options.map((option) => {
|
||||
const selected = option.id === value;
|
||||
const buttonClassName = [
|
||||
"segmented-control-option",
|
||||
selected ? "is-active" : "",
|
||||
optionClassName
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
role={optionRole}
|
||||
aria-selected={role === "tablist" ? selected : undefined}
|
||||
aria-pressed={role === "group" ? selected : undefined}
|
||||
aria-label={option.ariaLabel}
|
||||
title={option.title}
|
||||
className={buttonClassName}
|
||||
disabled={disabled || option.disabled}
|
||||
onClick={() => onChange(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export default function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||
return <span className={`status-badge status-${status.toLowerCase().replace(/_/g, "-")}`}>{label ?? status}</span>;
|
||||
const { translateText } = usePlatformLanguage();
|
||||
return <span className={`status-badge status-${status.toLowerCase().replace(/_/g, "-")}`}>{translateText(label ?? status)}</span>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import FieldLabel from "./help/FieldLabel";
|
||||
import { helpForFieldLabel } from "../utils/fieldHelp";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
type ToggleSwitchProps = {
|
||||
label: ReactNode;
|
||||
@@ -11,6 +12,8 @@ type ToggleSwitchProps = {
|
||||
};
|
||||
|
||||
export default function ToggleSwitch({ label, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const renderedLabel = typeof label === "string" ? translateText(label) : label;
|
||||
return (
|
||||
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}>
|
||||
<input
|
||||
@@ -22,7 +25,7 @@ export default function ToggleSwitch({ label, checked, onChange, disabled = fals
|
||||
/>
|
||||
<span className="toggle-switch-track" aria-hidden="true"><span className="toggle-switch-thumb" /></span>
|
||||
<span className="toggle-switch-copy">
|
||||
<FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{label}</FieldLabel>
|
||||
<FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{renderedLabel}</FieldLabel>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, type NavigateFunction, type NavigateOptions, type To } from "react-router-dom";
|
||||
import Button from "./Button";
|
||||
import Dialog from "./Dialog";
|
||||
import DismissibleAlert from "./DismissibleAlert";
|
||||
@@ -13,6 +13,15 @@ export type UnsavedChangesRegistration = {
|
||||
onDiscard?: () => void;
|
||||
};
|
||||
|
||||
export type UnsavedDraftGuardOptions = {
|
||||
dirty: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
onSave: () => boolean | Promise<boolean>;
|
||||
onDiscard?: () => void;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
type UnsavedChangesContextValue = {
|
||||
hasUnsavedChanges: boolean;
|
||||
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
|
||||
@@ -21,13 +30,15 @@ type UnsavedChangesContextValue = {
|
||||
|
||||
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
||||
|
||||
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
||||
const navigate = useNavigate();
|
||||
const [registration, setRegistration] = useState<UnsavedChangesRegistration | null>(null);
|
||||
const [registrations, setRegistrations] = useState<Array<{id: number;registration: UnsavedChangesRegistration;}>>([]);
|
||||
const [pendingAction, setPendingAction] = useState<UnsavedNavigationAction | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const registrationRef = useRef<UnsavedChangesRegistration | null>(null);
|
||||
const nextRegistrationId = useRef(1);
|
||||
const registration = registrations.length ? registrations[registrations.length - 1].registration : null;
|
||||
|
||||
useEffect(() => {
|
||||
registrationRef.current = registration;
|
||||
@@ -36,9 +47,12 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
const hasUnsavedChanges = Boolean(registration);
|
||||
|
||||
const registerUnsavedChanges = useCallback((next: UnsavedChangesRegistration | null) => {
|
||||
setRegistration(next);
|
||||
if (!next) return () => undefined;
|
||||
const id = nextRegistrationId.current;
|
||||
nextRegistrationId.current += 1;
|
||||
setRegistrations((current) => [...current, { id, registration: next }]);
|
||||
return () => {
|
||||
setRegistration((current) => current === next ? null : current);
|
||||
setRegistrations((current) => current.filter((item) => item.id !== id));
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -60,9 +74,12 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!registrationRef.current) return;
|
||||
const active = registrationRef.current;
|
||||
if (!active) return;
|
||||
const message = active.message || "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b";
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
event.returnValue = message;
|
||||
return message;
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
@@ -111,7 +128,7 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
const ok = await active.onSave();
|
||||
if (!ok) {
|
||||
setSaveError("The changes could not be saved. Please review the page message and try again.");
|
||||
setSaveError("i18n:govoplan-core.the_changes_could_not_be_saved_please_review_the.4615a3c6");
|
||||
return;
|
||||
}
|
||||
proceed(action);
|
||||
@@ -139,30 +156,30 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={value}>
|
||||
{children}
|
||||
{pendingAction && registration && (
|
||||
<Dialog
|
||||
open
|
||||
role="alertdialog"
|
||||
title={registration.title ?? "Unsaved changes"}
|
||||
className="unsaved-changes-dialog"
|
||||
footerClassName="unsaved-changes-actions"
|
||||
closeOnBackdrop={!saving}
|
||||
closeDisabled={saving}
|
||||
onClose={() => setPendingAction(null)}
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setPendingAction(null)} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={handleDiscardAndLeave} disabled={saving}>Discard</Button>
|
||||
<Button variant="primary" onClick={() => void handleSaveAndLeave()} disabled={saving}>{saving ? "Saving..." : "Save and leave"}</Button>
|
||||
{pendingAction && registration &&
|
||||
<Dialog
|
||||
open
|
||||
role="alertdialog"
|
||||
title={registration.title ?? "i18n:govoplan-core.unsaved_changes.29267269"}
|
||||
className="unsaved-changes-dialog"
|
||||
footerClassName="unsaved-changes-actions"
|
||||
closeOnBackdrop={!saving}
|
||||
closeDisabled={saving}
|
||||
onClose={() => setPendingAction(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setPendingAction(null)} disabled={saving}>i18n:govoplan-core.cancel.77dfd213</Button>
|
||||
<Button onClick={handleDiscardAndLeave} disabled={saving}>i18n:govoplan-core.discard.36fff63c</Button>
|
||||
<Button variant="primary" onClick={() => void handleSaveAndLeave()} disabled={saving}>{saving ? "i18n:govoplan-core.saving.ae7e8875" : "i18n:govoplan-core.save_and_leave.0507824a"}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p>{registration.message ?? "This page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p>
|
||||
}>
|
||||
|
||||
<p>{registration.message ?? "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b"}</p>
|
||||
{saveError && <DismissibleAlert tone="danger" resetKey={saveError}>{saveError}</DismissibleAlert>}
|
||||
</Dialog>
|
||||
)}
|
||||
</UnsavedChangesContext.Provider>
|
||||
);
|
||||
}
|
||||
</UnsavedChangesContext.Provider>);
|
||||
|
||||
}
|
||||
|
||||
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
|
||||
@@ -182,3 +199,44 @@ export function useRegisterUnsavedChanges(registration: UnsavedChangesRegistrati
|
||||
return registerUnsavedChanges(registration);
|
||||
}, [registerUnsavedChanges, registration]);
|
||||
}
|
||||
|
||||
export function useUnsavedDraftGuard({
|
||||
dirty,
|
||||
title = "i18n:govoplan-core.unsaved_changes.29267269",
|
||||
message = "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b",
|
||||
onSave,
|
||||
onDiscard,
|
||||
enabled = true
|
||||
}: UnsavedDraftGuardOptions) {
|
||||
const saveRef = useRef(onSave);
|
||||
const discardRef = useRef(onDiscard);
|
||||
|
||||
useEffect(() => {
|
||||
saveRef.current = onSave;
|
||||
discardRef.current = onDiscard;
|
||||
}, [onDiscard, onSave]);
|
||||
|
||||
const registration = useMemo<UnsavedChangesRegistration | null>(() => enabled && dirty ? {
|
||||
title,
|
||||
message,
|
||||
onSave: () => saveRef.current(),
|
||||
onDiscard: () => discardRef.current?.()
|
||||
} : null, [dirty, enabled, message, title]);
|
||||
|
||||
useRegisterUnsavedChanges(registration);
|
||||
}
|
||||
|
||||
export function useGuardedNavigate(): NavigateFunction {
|
||||
const navigate = useNavigate();
|
||||
const { requestNavigation } = useUnsavedChanges();
|
||||
|
||||
return useCallback(((to: To | number, options?: NavigateOptions) => {
|
||||
requestNavigation(() => {
|
||||
if (typeof to === "number") {
|
||||
navigate(to);
|
||||
} else {
|
||||
navigate(to, options);
|
||||
}
|
||||
});
|
||||
}) as NavigateFunction, [navigate, requestNavigation]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Button from "../Button";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
@@ -10,13 +11,15 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function AdminIconButton({ label, icon, onClick, disabled = false, variant = "secondary" }: Props) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const translatedLabel = translateText(label);
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
className="admin-icon-button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
aria-label={translatedLabel}
|
||||
title={translatedLabel}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
|
||||
import DismissibleAlert from "../DismissibleAlert";
|
||||
import LoadingFrame from "../LoadingFrame";
|
||||
import PageTitle from "../PageTitle";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
@@ -19,27 +20,28 @@ export default function AdminPageLayout({
|
||||
title,
|
||||
description,
|
||||
loading = false,
|
||||
loadingLabel = "Loading administration data...",
|
||||
loadingLabel = "i18n:govoplan-core.loading_administration_data.643bd894",
|
||||
error = "",
|
||||
success = "",
|
||||
actions,
|
||||
children,
|
||||
className = ""
|
||||
}: Props) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
return (
|
||||
<div className={`admin-section-page ${className}`.trim()}>
|
||||
<div className="page-heading split workspace-heading admin-page-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>{title}</PageTitle>
|
||||
<p>{description}</p>
|
||||
<p>{translateText(description)}</p>
|
||||
</div>
|
||||
{actions && <div className="button-row compact-actions admin-page-actions">{actions}</div>}
|
||||
</div>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
<LoadingFrame loading={loading} label={loadingLabel}>
|
||||
<LoadingFrame loading={loading} label={translateText(loadingLabel)}>
|
||||
{children}
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
|
||||
type Option = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -9,36 +11,37 @@ export default function AdminSelectionList({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
emptyText = "No options available."
|
||||
}: {
|
||||
options: Option[];
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
emptyText?: string;
|
||||
}) {
|
||||
if (!options.length) return <p className="muted small-note">{emptyText}</p>;
|
||||
emptyText = "i18n:govoplan-core.no_options_available.a88ab045"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {options: Option[];selected: string[];onChange: (next: string[]) => void;emptyText?: string;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
if (!options.length) return <p className="muted small-note">{translateText(emptyText)}</p>;
|
||||
const selectedSet = new Set(selected);
|
||||
return (
|
||||
<div className="admin-selection-list">
|
||||
{options.map((option) => (
|
||||
<label key={option.id} className={`admin-selection-item ${option.disabled ? "disabled" : ""}`}>
|
||||
{options.map((option) =>
|
||||
<label key={option.id} className={`admin-selection-item ${option.disabled ? "disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSet.has(option.id)}
|
||||
disabled={option.disabled}
|
||||
onChange={(event) => {
|
||||
const next = new Set(selectedSet);
|
||||
if (event.target.checked) next.add(option.id);
|
||||
else next.delete(option.id);
|
||||
onChange(Array.from(next));
|
||||
}}
|
||||
/>
|
||||
type="checkbox"
|
||||
checked={selectedSet.has(option.id)}
|
||||
disabled={option.disabled}
|
||||
onChange={(event) => {
|
||||
const next = new Set(selectedSet);
|
||||
if (event.target.checked) next.add(option.id);else
|
||||
next.delete(option.id);
|
||||
onChange(Array.from(next));
|
||||
}} />
|
||||
|
||||
<span>
|
||||
<strong>{option.label}</strong>
|
||||
{option.description && <small>{option.description}</small>}
|
||||
<strong>{translateText(option.label)}</strong>
|
||||
{option.description && <small>{translateText(option.description)}</small>}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,27 +1,41 @@
|
||||
import { formatDateTime as formatPlatformDateTime } from "../../utils/datetime";
|
||||
|
||||
export function adminErrorMessage(error: unknown): string {
|
||||
if (!(error instanceof Error)) return "The administrative operation failed.";
|
||||
if (!(error instanceof Error)) return "i18n:govoplan-core.the_administrative_operation_failed.0791d379";
|
||||
const message = error.message;
|
||||
const jsonStart = message.indexOf("{");
|
||||
if (jsonStart >= 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(message.slice(jsonStart)) as { detail?: unknown };
|
||||
const parsed = JSON.parse(message.slice(jsonStart)) as {detail?: unknown;};
|
||||
if (typeof parsed.detail === "string") return parsed.detail;
|
||||
if (Array.isArray(parsed.detail)) {
|
||||
return parsed.detail.map((item) => typeof item === "object" && item && "msg" in item ? String((item as { msg: unknown }).msg) : String(item)).join("; ");
|
||||
return parsed.detail.map((item) => typeof item === "object" && item && "msg" in item ? String((item as {msg: unknown;}).msg) : String(item)).join("; ");
|
||||
}
|
||||
if (typeof parsed.detail === "object" && parsed.detail) {
|
||||
const detail = parsed.detail as {message?: unknown;plan?: {blockers?: unknown;warnings?: unknown;};};
|
||||
const blockers = detail.plan?.blockers;
|
||||
const warnings = detail.plan?.warnings;
|
||||
const parts = [];
|
||||
if (typeof detail.message === "string") parts.push(detail.message);
|
||||
if (Array.isArray(blockers) && blockers.length > 0) {
|
||||
parts.push(`Blockers: ${blockers.map(String).join(", ")}`);
|
||||
}
|
||||
if (Array.isArray(warnings) && warnings.length > 0) {
|
||||
parts.push(`Warnings: ${warnings.map(String).join(", ")}`);
|
||||
}
|
||||
if (parts.length > 0) return parts.join(" ");
|
||||
}
|
||||
} catch {
|
||||
|
||||
|
||||
// Fall through to the original message.
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}}return message;
|
||||
}
|
||||
|
||||
export function formatAdminDateTime(value?: string | null): string {
|
||||
return formatPlatformDateTime(value, { fallback: "Never" });
|
||||
return formatPlatformDateTime(value, { fallback: "i18n:govoplan-core.never.80c3052d" });
|
||||
}
|
||||
|
||||
export function joinLabels(values: Array<{ name: string }>): string {
|
||||
export function joinLabels(values: Array<{name: string;}>): string {
|
||||
return values.length ? values.map((value) => value.name).join(", ") : "—";
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { i18nMessage } from "../../i18n/LanguageContext";import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, KeyboardEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Button from "../Button";
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
isValidEmailAddress,
|
||||
normalizeEmailAddress,
|
||||
parseMailboxAddressText,
|
||||
type MailboxAddress
|
||||
} from "../../utils/emailAddresses";
|
||||
type MailboxAddress } from
|
||||
"../../utils/emailAddresses";
|
||||
|
||||
type EmailAddressInputProps = {
|
||||
value: MailboxAddress[];
|
||||
@@ -35,10 +35,10 @@ export default function EmailAddressInput({
|
||||
allowMultiple = true,
|
||||
clearOnAdd = false,
|
||||
disabled = false,
|
||||
addLabel = "Add",
|
||||
namePlaceholder = "Name",
|
||||
addLabel = "i18n:govoplan-core.add.61cc55aa",
|
||||
namePlaceholder = "i18n:govoplan-core.name.709a2322",
|
||||
emailPlaceholder = "email@example.org",
|
||||
emptyText = "No address added yet.",
|
||||
emptyText = "i18n:govoplan-core.no_address_added_yet.809c4247",
|
||||
compact = false,
|
||||
showAddButton
|
||||
}: EmailAddressInputProps) {
|
||||
@@ -57,9 +57,9 @@ export default function EmailAddressInput({
|
||||
const filteredSuggestions = useMemo(() => {
|
||||
const query = entryText.trim().toLowerCase();
|
||||
if (!query) return normalizedSuggestions.slice(0, 6);
|
||||
return normalizedSuggestions
|
||||
.filter((item) => `${item.name ?? ""} ${item.email}`.toLowerCase().includes(query))
|
||||
.slice(0, 6);
|
||||
return normalizedSuggestions.
|
||||
filter((item) => `${item.name ?? ""} ${item.email}`.toLowerCase().includes(query)).
|
||||
slice(0, 6);
|
||||
}, [entryText, normalizedSuggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -103,17 +103,17 @@ export default function EmailAddressInput({
|
||||
function commitAddress(candidate: MailboxAddress | null, sourceText = "") {
|
||||
if (disabled) return false;
|
||||
if (!candidate?.email) {
|
||||
setError(sourceText ? "Use a valid address such as Name <email@example.org>." : "Enter an email address first.");
|
||||
setError(sourceText ? "i18n:govoplan-core.use_a_valid_address_such_as_name_email_example_o.39cdbc3c" : "i18n:govoplan-core.enter_an_email_address_first.6d55dfd1");
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = normalizeEmailAddress(candidate);
|
||||
if (!isValidEmailAddress(normalized.email)) {
|
||||
setError("Enter a valid email address.");
|
||||
setError("i18n:govoplan-core.enter_a_valid_email_address.2e09edea");
|
||||
return false;
|
||||
}
|
||||
if (allowMultiple && normalizedValue.some((address) => address.email === normalized.email)) {
|
||||
setError("This address is already listed.");
|
||||
setError("i18n:govoplan-core.this_address_is_already_listed.0f6d0ff2");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -166,19 +166,19 @@ export default function EmailAddressInput({
|
||||
aria-labelledby={`${inputId}-dialog-title`}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") setDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
<h4 id={`${inputId}-dialog-title`}>Add address</h4>
|
||||
}}>
|
||||
|
||||
<h4 id={`${inputId}-dialog-title`}>i18n:govoplan-core.add_address.a71075c4</h4>
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<span>i18n:govoplan-core.name.709a2322</span>
|
||||
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={namePlaceholder} autoFocus />
|
||||
</label>
|
||||
<label>
|
||||
<span>Email address</span>
|
||||
<span>i18n:govoplan-core.email_address.c94d3175</span>
|
||||
<input value={dialogEmail} onChange={(event) => setDialogEmail(event.target.value)} placeholder={emailPlaceholder} inputMode="email" />
|
||||
</label>
|
||||
<div className="button-row compact-actions">
|
||||
<Button type="button" onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button type="button" onClick={() => setDialogOpen(false)}>i18n:govoplan-core.cancel.77dfd213</Button>
|
||||
<Button type="button" variant="primary" onClick={commitDialogAddress}>{addLabel}</Button>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -193,54 +193,54 @@ export default function EmailAddressInput({
|
||||
{normalizedValue.map((address) => {
|
||||
const valid = isValidEmailAddress(address.email);
|
||||
return (
|
||||
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : "Invalid email address"}>
|
||||
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : "i18n:govoplan-core.invalid_email_address.9e4ee6d7"}>
|
||||
<span className="email-chip-main">{addressDisplayName(address)}</span>
|
||||
{address.name && <span className="email-chip-address">{address.email}</span>}
|
||||
{!disabled && (
|
||||
<button type="button" className="email-chip-remove" aria-label={`Remove ${address.email}`} onClick={() => removeAddress(address.email)}>
|
||||
{!disabled &&
|
||||
<button type="button" className="email-chip-remove" aria-label={i18nMessage("i18n:govoplan-core.remove_value.a2d40d33", { value0: address.email })} onClick={() => removeAddress(address.email)}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
</span>);
|
||||
|
||||
})}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<>
|
||||
{!disabled &&
|
||||
<>
|
||||
<textarea
|
||||
id={inputId}
|
||||
className="email-address-textarea"
|
||||
rows={1}
|
||||
value={entryText}
|
||||
onChange={(event) => {
|
||||
setEntryText(event.target.value);
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={handleTextKeyDown}
|
||||
placeholder={`${namePlaceholder} <${emailPlaceholder}>`}
|
||||
aria-label="Type a name and email address, then press Enter"
|
||||
/>
|
||||
{canUseAddButton && (
|
||||
<button ref={addButtonRef} type="button" className="email-address-plus" aria-label="Open address form" title="Add address with form" onClick={() => setDialogOpen((open) => !open)}>
|
||||
id={inputId}
|
||||
className="email-address-textarea"
|
||||
rows={1}
|
||||
value={entryText}
|
||||
onChange={(event) => {
|
||||
setEntryText(event.target.value);
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={handleTextKeyDown}
|
||||
placeholder={i18nMessage("i18n:govoplan-core.value_value.27085f09", { value0: namePlaceholder, value1: emailPlaceholder })}
|
||||
aria-label="i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0" />
|
||||
|
||||
{canUseAddButton &&
|
||||
<button ref={addButtonRef} type="button" className="email-address-plus" aria-label="i18n:govoplan-core.open_address_form.f8ee560f" title="i18n:govoplan-core.add_address_with_form.13b3b3e7" onClick={() => setDialogOpen((open) => !open)}>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
}
|
||||
</>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
|
||||
{!disabled && filteredSuggestions.length > 0 && entryText.trim() && (
|
||||
<div className="email-address-suggestions" role="listbox" aria-label="Address suggestions">
|
||||
{filteredSuggestions.map((item) => (
|
||||
<button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option">
|
||||
{!disabled && filteredSuggestions.length > 0 && entryText.trim() &&
|
||||
<div className="email-address-suggestions" role="listbox" aria-label="i18n:govoplan-core.address_suggestions.45ba4a20">
|
||||
{filteredSuggestions.map((item) =>
|
||||
<button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option">
|
||||
<span>{addressDisplayName(item)}</span>
|
||||
{item.name && <small>{item.email}</small>}
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
{error && <p className="form-help danger-text">{error}</p>}
|
||||
{addressDialog}
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
|
||||
type InlineHelpProps = {
|
||||
children: ReactNode;
|
||||
@@ -42,6 +43,7 @@ function clamp(value: number, min: number, max: number) {
|
||||
}
|
||||
|
||||
export default function InlineHelp({ children, className = "" }: InlineHelpProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const tooltipId = useId();
|
||||
const triggerRef = useRef<HTMLSpanElement | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -82,9 +84,9 @@ export default function InlineHelp({ children, className = "" }: InlineHelpProps
|
||||
const topCandidate = triggerRect.top - tooltipRect.height - TRIGGER_GAP;
|
||||
const hasRoomAbove = topCandidate >= VIEWPORT_MARGIN;
|
||||
const bottomCandidate = triggerRect.bottom + TRIGGER_GAP;
|
||||
const top = hasRoomAbove
|
||||
? topCandidate
|
||||
: clamp(bottomCandidate, VIEWPORT_MARGIN, window.innerHeight - tooltipRect.height - VIEWPORT_MARGIN);
|
||||
const top = hasRoomAbove ?
|
||||
topCandidate :
|
||||
clamp(bottomCandidate, VIEWPORT_MARGIN, window.innerHeight - tooltipRect.height - VIEWPORT_MARGIN);
|
||||
|
||||
setPosition({
|
||||
top,
|
||||
@@ -135,52 +137,52 @@ export default function InlineHelp({ children, className = "" }: InlineHelpProps
|
||||
opacity: position ? 1 : 0
|
||||
};
|
||||
|
||||
const arrowStyle: CSSProperties = position?.placement === "bottom"
|
||||
? {
|
||||
position: "absolute",
|
||||
left: position.arrowLeft,
|
||||
top: 0,
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderLeft: "1px solid var(--line-dark)",
|
||||
borderTop: "1px solid var(--line-dark)",
|
||||
background: "var(--surface)",
|
||||
transform: "translate(-50%, -5px) rotate(45deg)"
|
||||
}
|
||||
: {
|
||||
position: "absolute",
|
||||
left: position?.arrowLeft ?? 16,
|
||||
top: "100%",
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRight: "1px solid var(--line-dark)",
|
||||
borderBottom: "1px solid var(--line-dark)",
|
||||
background: "var(--surface)",
|
||||
transform: "translate(-50%, -5px) rotate(45deg)"
|
||||
};
|
||||
const arrowStyle: CSSProperties = position?.placement === "bottom" ?
|
||||
{
|
||||
position: "absolute",
|
||||
left: position.arrowLeft,
|
||||
top: 0,
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderLeft: "1px solid var(--line-dark)",
|
||||
borderTop: "1px solid var(--line-dark)",
|
||||
background: "var(--surface)",
|
||||
transform: "translate(-50%, -5px) rotate(45deg)"
|
||||
} :
|
||||
{
|
||||
position: "absolute",
|
||||
left: position?.arrowLeft ?? 16,
|
||||
top: "100%",
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRight: "1px solid var(--line-dark)",
|
||||
borderBottom: "1px solid var(--line-dark)",
|
||||
background: "var(--surface)",
|
||||
transform: "translate(-50%, -5px) rotate(45deg)"
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={triggerRef}
|
||||
className={`inline-help ${className}`.trim()}
|
||||
tabIndex={0}
|
||||
aria-label="Show field help"
|
||||
tabIndex={-1}
|
||||
aria-label={translateText("i18n:govoplan-core.show_field_help.e3dfe98f")}
|
||||
aria-describedby={isOpen ? tooltipId : undefined}
|
||||
onMouseEnter={openWithDelay}
|
||||
onMouseLeave={close}
|
||||
onFocus={openWithDelay}
|
||||
onBlur={close}
|
||||
>
|
||||
onBlur={close}>
|
||||
|
||||
<span className="inline-help-mark" aria-hidden="true">?</span>
|
||||
</span>
|
||||
{isOpen && createPortal(
|
||||
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
|
||||
{children}
|
||||
{typeof children === "string" ? translateText(children) : children}
|
||||
<span aria-hidden="true" style={arrowStyle} />
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import Button from "../Button";
|
||||
import DismissibleAlert from "../DismissibleAlert";
|
||||
import FormField from "../FormField";
|
||||
import PasswordField from "../PasswordField";
|
||||
import SegmentedControl from "../SegmentedControl";
|
||||
import ToggleSwitch from "../ToggleSwitch";
|
||||
|
||||
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
|
||||
@@ -43,7 +44,7 @@ export type MailServerFolderLookupResult = {
|
||||
security?: MailServerSecurity | null;
|
||||
message: string;
|
||||
detected_sent_folder?: string | null;
|
||||
folders?: { name: string; flags?: string[] }[];
|
||||
folders?: {name: string;flags?: string[];}[];
|
||||
details?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
@@ -133,19 +134,19 @@ export function mailNumberOrDefault(value: string | number | null | undefined, f
|
||||
}
|
||||
|
||||
export function normalizeMailServerSecurity<TSecurity extends string = MailServerSecurityOption>(
|
||||
value: string | null | undefined,
|
||||
options: { fallback: TSecurity; allowedSecurity?: readonly TSecurity[] },
|
||||
): TSecurity {
|
||||
const allowed = options.allowedSecurity ?? (mailServerSecurityOptions as unknown as readonly TSecurity[]);
|
||||
return allowed.includes(value as TSecurity) ? (value as TSecurity) : options.fallback;
|
||||
value: string | null | undefined,
|
||||
options: {fallback: TSecurity;allowedSecurity?: readonly TSecurity[];})
|
||||
: TSecurity {
|
||||
const allowed = options.allowedSecurity ?? mailServerSecurityOptions as unknown as readonly TSecurity[];
|
||||
return allowed.includes(value as TSecurity) ? value as TSecurity : options.fallback;
|
||||
}
|
||||
|
||||
export function mailTransportCredentialsPayload(
|
||||
username: string | number | null | undefined,
|
||||
password: string | number | null | undefined,
|
||||
preserveBlankPassword: boolean,
|
||||
): { username?: string | null; password?: string | null } {
|
||||
const payload: { username?: string | null; password?: string | null } = { username: mailTextOrNull(username) };
|
||||
username: string | number | null | undefined,
|
||||
password: string | number | null | undefined,
|
||||
preserveBlankPassword: boolean)
|
||||
: {username?: string | null;password?: string | null;} {
|
||||
const payload: {username?: string | null;password?: string | null;} = { username: mailTextOrNull(username) };
|
||||
if (String(password ?? "") || !preserveBlankPassword) payload.password = mailTextOrNull(password, false);
|
||||
return payload;
|
||||
}
|
||||
@@ -157,39 +158,39 @@ function mailRecordText(record: Record<string, unknown>, key: string, fallback =
|
||||
}
|
||||
|
||||
export function mailTransportCredentialsPayloadFromRecords(
|
||||
primary: Record<string, unknown>,
|
||||
legacy: Record<string, unknown>,
|
||||
preserveBlankPassword: boolean,
|
||||
): { username?: string | null; password?: string | null } {
|
||||
primary: Record<string, unknown>,
|
||||
legacy: Record<string, unknown>,
|
||||
preserveBlankPassword: boolean)
|
||||
: {username?: string | null;password?: string | null;} {
|
||||
return mailTransportCredentialsPayload(
|
||||
mailRecordText(primary, "username", mailRecordText(legacy, "username")),
|
||||
mailRecordText(primary, "password", mailRecordText(legacy, "password")),
|
||||
preserveBlankPassword,
|
||||
preserveBlankPassword
|
||||
);
|
||||
}
|
||||
|
||||
export function mailSmtpSettingsPayload<TSecurity extends string = MailServerSecurityOption>(
|
||||
settings: MailServerSmtpSettings,
|
||||
options: { fallbackSecurity: TSecurity; allowedSecurity?: readonly TSecurity[]; fallbackTimeoutSeconds?: number },
|
||||
): { host: string | null; port: number | null; security: TSecurity; timeout_seconds: number } {
|
||||
settings: MailServerSmtpSettings,
|
||||
options: {fallbackSecurity: TSecurity;allowedSecurity?: readonly TSecurity[];fallbackTimeoutSeconds?: number;})
|
||||
: {host: string | null;port: number | null;security: TSecurity;timeout_seconds: number;} {
|
||||
return {
|
||||
host: mailTextOrNull(settings.host),
|
||||
port: mailNumberOrNull(settings.port),
|
||||
security: normalizeMailServerSecurity(settings.security ? String(settings.security) : null, { fallback: options.fallbackSecurity, allowedSecurity: options.allowedSecurity }),
|
||||
timeout_seconds: mailNumberOrDefault(settings.timeout_seconds, options.fallbackTimeoutSeconds ?? 30),
|
||||
timeout_seconds: mailNumberOrDefault(settings.timeout_seconds, options.fallbackTimeoutSeconds ?? 30)
|
||||
};
|
||||
}
|
||||
|
||||
export function mailImapSettingsPayload<TSecurity extends string = MailServerSecurityOption>(
|
||||
settings: MailServerImapSettings,
|
||||
options: { fallbackSecurity: TSecurity; allowedSecurity?: readonly TSecurity[]; fallbackTimeoutSeconds?: number },
|
||||
): { host: string | null; port: number | null; security: TSecurity; sent_folder: string; timeout_seconds: number } {
|
||||
settings: MailServerImapSettings,
|
||||
options: {fallbackSecurity: TSecurity;allowedSecurity?: readonly TSecurity[];fallbackTimeoutSeconds?: number;})
|
||||
: {host: string | null;port: number | null;security: TSecurity;sent_folder: string;timeout_seconds: number;} {
|
||||
return {
|
||||
host: mailTextOrNull(settings.host),
|
||||
port: mailNumberOrNull(settings.port),
|
||||
security: normalizeMailServerSecurity(settings.security ? String(settings.security) : null, { fallback: options.fallbackSecurity, allowedSecurity: options.allowedSecurity }),
|
||||
sent_folder: mailTextOrNull(settings.sent_folder) || "auto",
|
||||
timeout_seconds: mailNumberOrDefault(settings.timeout_seconds, options.fallbackTimeoutSeconds ?? 30),
|
||||
timeout_seconds: mailNumberOrDefault(settings.timeout_seconds, options.fallbackTimeoutSeconds ?? 30)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,9 +217,9 @@ export default function MailServerSettingsPanel({
|
||||
imapPasswordSaved = false,
|
||||
imapSavedPasswordPlaceholder = "••••••••",
|
||||
imapActionDisabled = imapServerDisabled,
|
||||
smtpTestLabel = "Test SMTP",
|
||||
imapTestLabel = "Test IMAP",
|
||||
folderLookupLabel = "Folders...",
|
||||
smtpTestLabel = "i18n:govoplan-core.test_smtp.e5697981",
|
||||
imapTestLabel = "i18n:govoplan-core.test_imap.ef1bd79c",
|
||||
folderLookupLabel = "i18n:govoplan-core.folders.c603ab65",
|
||||
busyAction = null,
|
||||
onTestSmtp,
|
||||
onTestImap,
|
||||
@@ -250,15 +251,15 @@ export default function MailServerSettingsPanel({
|
||||
const appendTargetFolder = append ? append.folder : stringValue(imap.sent_folder, "auto");
|
||||
const appendTargetDisabled = append ? disabled || append.folderDisabled : imapFieldsDisabled;
|
||||
const canLookupAppendFolders = Boolean(onLookupFolders) && !appendTargetDisabled;
|
||||
const appendTargetHelp = append
|
||||
? "Folder for sent-message copies. Leave as auto unless this campaign needs a different target."
|
||||
: "Folder used when this IMAP account is used for sent-message copies. Leave as auto to use the server default.";
|
||||
const appendTargetHelp = append ?
|
||||
"i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9" :
|
||||
"i18n:govoplan-core.folder_used_when_this_imap_account_is_used_for_s.08503f5e";
|
||||
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(initialSection);
|
||||
const sections: { id: MailServerSettingsSection; label: string }[] = [
|
||||
{ id: "smtp", label: "SMTP" },
|
||||
{ id: "imap", label: "IMAP" },
|
||||
{ id: "advanced", label: "Advanced" }
|
||||
];
|
||||
const sections: {id: MailServerSettingsSection;label: string;}[] = [
|
||||
{ id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" },
|
||||
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" },
|
||||
{ id: "advanced", label: "i18n:govoplan-core.advanced.4d064726" }];
|
||||
|
||||
|
||||
function patchSmtpSecurity(security: MailServerSecurity) {
|
||||
const port = portForSecurityChange("smtp", smtp.port, smtpSecurity, security);
|
||||
@@ -271,153 +272,147 @@ export default function MailServerSettingsPanel({
|
||||
}
|
||||
|
||||
function patchSmtpCredentials(patch: Partial<MailServerCredentialSettings>) {
|
||||
if (onSmtpCredentialsChange) onSmtpCredentialsChange(patch);
|
||||
else onSmtpChange(patch);
|
||||
if (onSmtpCredentialsChange) onSmtpCredentialsChange(patch);else
|
||||
onSmtpChange(patch);
|
||||
}
|
||||
|
||||
function patchImapCredentials(patch: Partial<MailServerCredentialSettings>) {
|
||||
if (onImapCredentialsChange) onImapCredentialsChange(patch);
|
||||
else onImapChange(patch);
|
||||
if (onImapCredentialsChange) onImapCredentialsChange(patch);else
|
||||
onImapChange(patch);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mail-server-settings-panel ${className}`.trim()}>
|
||||
<div className="mail-server-segmented-control" role="tablist" aria-label="Mail server settings sections">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
type="button"
|
||||
key={section.id}
|
||||
role="tab"
|
||||
aria-selected={activeSection === section.id}
|
||||
className={`mail-server-segmented-tab${activeSection === section.id ? " is-active" : ""}`}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedControl
|
||||
className="mail-server-segmented-control"
|
||||
size="equal"
|
||||
ariaLabel="i18n:govoplan-core.mail_server_settings_sections.40a163b7"
|
||||
value={activeSection}
|
||||
onChange={setActiveSection}
|
||||
options={sections}
|
||||
/>
|
||||
|
||||
<div className="mail-server-settings-view">
|
||||
{activeSection === "smtp" && (
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="SMTP settings">
|
||||
{activeSection === "smtp" &&
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.smtp_settings.f103e570">
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
<div className="mail-server-field-heading">Server</div>
|
||||
<FormField label="Host"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="Port"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="Security"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
|
||||
<div className="mail-server-field-heading">Credentials</div>
|
||||
<FormField label="Username"><input value={stringValue(smtpCredentialValues.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => patchSmtpCredentials({ username: event.target.value })} /></FormField>
|
||||
<FormField label="Password">
|
||||
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
|
||||
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
|
||||
<div className="mail-server-field-heading">i18n:govoplan-core.credentials.dd097a22</div>
|
||||
<FormField label="i18n:govoplan-core.username.84c29015"><input value={stringValue(smtpCredentialValues.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => patchSmtpCredentials({ username: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.password.8be3c943">
|
||||
<PasswordField
|
||||
value={stringValue(smtpCredentialValues.password)}
|
||||
disabled={smtpCredentialFieldsDisabled}
|
||||
saved={smtpPasswordSaved}
|
||||
savedPlaceholder={smtpSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => patchSmtpCredentials({ password })}
|
||||
/>
|
||||
value={stringValue(smtpCredentialValues.password)}
|
||||
disabled={smtpCredentialFieldsDisabled}
|
||||
saved={smtpPasswordSaved}
|
||||
savedPlaceholder={smtpSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => patchSmtpCredentials({ password })} />
|
||||
|
||||
</FormField>
|
||||
</div>
|
||||
{onTestSmtp && (
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
<Button type="button" variant="primary" onClick={onTestSmtp} disabled={smtpActionsDisabled || busyAction === "smtp"}>{busyAction === "smtp" ? "Testing..." : smtpTestLabel}</Button>
|
||||
{onTestSmtp &&
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
<Button type="button" variant="primary" onClick={onTestSmtp} disabled={smtpActionsDisabled || busyAction === "smtp"}>{busyAction === "smtp" ? "i18n:govoplan-core.testing.15ccc832" : smtpTestLabel}</Button>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
<MailServerActionResult result={smtpTestResult} floating={floatingResults} />
|
||||
</section>
|
||||
)}
|
||||
}
|
||||
|
||||
{activeSection === "imap" && (
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="IMAP settings">
|
||||
{activeSection === "imap" &&
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.imap_settings.ab8d8247">
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
<div className="mail-server-field-heading">Server</div>
|
||||
<FormField label="Host"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="Port"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="Security"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
|
||||
<div className="mail-server-field-heading">Credentials</div>
|
||||
<FormField label="Username"><input value={stringValue(imapCredentialValues.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => patchImapCredentials({ username: event.target.value })} /></FormField>
|
||||
<FormField label="Password">
|
||||
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
|
||||
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
|
||||
<div className="mail-server-field-heading">i18n:govoplan-core.credentials.dd097a22</div>
|
||||
<FormField label="i18n:govoplan-core.username.84c29015"><input value={stringValue(imapCredentialValues.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => patchImapCredentials({ username: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.password.8be3c943">
|
||||
<PasswordField
|
||||
value={stringValue(imapCredentialValues.password)}
|
||||
disabled={imapCredentialFieldsDisabled}
|
||||
saved={imapPasswordSaved}
|
||||
savedPlaceholder={imapSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => patchImapCredentials({ password })}
|
||||
/>
|
||||
value={stringValue(imapCredentialValues.password)}
|
||||
disabled={imapCredentialFieldsDisabled}
|
||||
saved={imapPasswordSaved}
|
||||
savedPlaceholder={imapSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => patchImapCredentials({ password })} />
|
||||
|
||||
</FormField>
|
||||
</div>
|
||||
{onTestImap && (
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
<Button type="button" variant="primary" onClick={onTestImap} disabled={imapActionsDisabled || busyAction === "imap"}>{busyAction === "imap" ? "Testing..." : imapTestLabel}</Button>
|
||||
{onTestImap &&
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
<Button type="button" variant="primary" onClick={onTestImap} disabled={imapActionsDisabled || busyAction === "imap"}>{busyAction === "imap" ? "i18n:govoplan-core.testing.15ccc832" : imapTestLabel}</Button>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
<MailServerActionResult result={imapTestResult} floating={floatingResults} />
|
||||
</section>
|
||||
)}
|
||||
}
|
||||
|
||||
{activeSection === "advanced" && (
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="Advanced mail settings">
|
||||
{activeSection === "advanced" &&
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.advanced_mail_settings.1f69439a">
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid mail-server-advanced-grid">
|
||||
{mockToggle && (
|
||||
<div className="mail-server-field-span mail-server-toggle-row">
|
||||
{mockToggle &&
|
||||
<div className="mail-server-field-span mail-server-toggle-row">
|
||||
<ToggleSwitch
|
||||
label={mockToggle.label ?? "Mock server settings"}
|
||||
checked={mockToggle.checked}
|
||||
disabled={disabled || mockToggle.disabled}
|
||||
onChange={mockToggle.onChange}
|
||||
/>
|
||||
label={mockToggle.label ?? "i18n:govoplan-core.mock_server_settings.9361d5c5"}
|
||||
checked={mockToggle.checked}
|
||||
disabled={disabled || mockToggle.disabled}
|
||||
onChange={mockToggle.onChange} />
|
||||
|
||||
</div>
|
||||
)}
|
||||
<FormField label="SMTP timeout seconds"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
<FormField label="IMAP timeout seconds"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
{append && (
|
||||
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
||||
<ToggleSwitch label="Append successfully sent messages to Sent" checked={append.enabled} disabled={disabled || append.disabled} onChange={append.onEnabledChange} />
|
||||
}
|
||||
<FormField label="i18n:govoplan-core.smtp_timeout_seconds.ac87c8d2"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.imap_timeout_seconds.489af2a4"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||
{append &&
|
||||
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
||||
<ToggleSwitch label="i18n:govoplan-core.append_successfully_sent_messages_to_sent.002fd67e" checked={append.enabled} disabled={disabled || append.disabled} onChange={append.onEnabledChange} />
|
||||
</div>
|
||||
)}
|
||||
<FormField label="Append target folder" help={appendTargetHelp}>
|
||||
}
|
||||
<FormField label="i18n:govoplan-core.append_target_folder.0aaacc0c" help={appendTargetHelp}>
|
||||
<div className="field-with-action mail-server-folder-field">
|
||||
<input
|
||||
value={appendTargetFolder}
|
||||
disabled={appendTargetDisabled}
|
||||
onChange={(event) => append ? append.onFolderChange(event.target.value) : onImapChange({ sent_folder: event.target.value })}
|
||||
placeholder="auto"
|
||||
/>
|
||||
{canLookupAppendFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "Looking up..." : folderLookupLabel}</Button>}
|
||||
value={appendTargetFolder}
|
||||
disabled={appendTargetDisabled}
|
||||
onChange={(event) => append ? append.onFolderChange(event.target.value) : onImapChange({ sent_folder: event.target.value })}
|
||||
placeholder="i18n:govoplan-core.auto.0d612c12" />
|
||||
|
||||
{canLookupAppendFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "i18n:govoplan-core.looking_up.5fc6d2a2" : folderLookupLabel}</Button>}
|
||||
</div>
|
||||
</FormField>
|
||||
{canLookupAppendFolders && <MailServerFolderLookupResultView result={folderLookupResult} disabled={useDetectedFolderDisabled || appendTargetDisabled} onUseDetected={onUseDetectedFolder} />}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function MailServerActionResult({ result, floating = false }: { result: MailServerConnectionTestResult | null | undefined; floating?: boolean }) {
|
||||
export function MailServerActionResult({ result, floating = false }: {result: MailServerConnectionTestResult | null | undefined;floating?: boolean;}) {
|
||||
if (!result) return null;
|
||||
const authenticated = result.details?.authenticated;
|
||||
return (
|
||||
<DismissibleAlert tone={result.ok ? "success" : "danger"} resetKey={`${result.ok}:${result.message}`} floating={floating}>
|
||||
{result.message}
|
||||
{result.ok && typeof authenticated === "boolean" && (
|
||||
<span> Authentication: {authenticated ? "credentials accepted" : "not used"}.</span>
|
||||
)}
|
||||
</DismissibleAlert>
|
||||
);
|
||||
{result.ok && typeof authenticated === "boolean" &&
|
||||
<span> i18n:govoplan-core.authentication.e665f157 {authenticated ? "i18n:govoplan-core.credentials_accepted.ae8e2629" : "i18n:govoplan-core.not_used.487f8bcd"}.</span>
|
||||
}
|
||||
</DismissibleAlert>);
|
||||
|
||||
}
|
||||
|
||||
export function MailServerFolderLookupResultView({
|
||||
result,
|
||||
disabled = false,
|
||||
onUseDetected
|
||||
}: {
|
||||
result: MailServerFolderLookupResult | null | undefined;
|
||||
disabled?: boolean;
|
||||
onUseDetected?: () => void;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;}) {
|
||||
if (!result) return null;
|
||||
if (!result.ok) {
|
||||
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>;
|
||||
@@ -427,21 +422,21 @@ export function MailServerFolderLookupResultView({
|
||||
return (
|
||||
<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 && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>Use detected folder</Button>}
|
||||
{folders.length > 0 && (
|
||||
<div className="mail-server-folder-chip-list">
|
||||
{folders.slice(0, 12).map((folder) => (
|
||||
<span className="mail-server-folder-chip" key={folder.name} title={(folder.flags || []).join(" ")}>{folder.name}</span>
|
||||
))}
|
||||
<p>i18n:govoplan-core.detected_sent_folder.cbf8ec8d <strong>{result.detected_sent_folder || "-"}</strong></p>
|
||||
{result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>i18n:govoplan-core.use_detected_folder.5ec4965c</Button>}
|
||||
{folders.length > 0 &&
|
||||
<div className="mail-server-folder-chip-list">
|
||||
{folders.slice(0, 12).map((folder) =>
|
||||
<span className="mail-server-folder-chip" key={folder.name} title={(folder.flags || []).join(" ")}>{folder.name}</span>
|
||||
)}
|
||||
{folders.length > 12 && <span className="mail-server-folder-chip">+{folders.length - 12} more</span>}
|
||||
</div>
|
||||
)}
|
||||
</DismissibleAlert>
|
||||
);
|
||||
}
|
||||
</DismissibleAlert>);
|
||||
|
||||
}
|
||||
|
||||
function SecuritySelect({ value, disabled, onChange }: { value: string; disabled: boolean; onChange: (value: MailServerSecurity) => void }) {
|
||||
function SecuritySelect({ value, disabled, onChange }: {value: string;disabled: boolean;onChange: (value: MailServerSecurity) => void;}) {
|
||||
return <select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>{securityOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user