chore: consolidate platform split checks
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Module Matrix / module-matrix (push) Has been cancelled

This commit is contained in:
2026-07-10 12:51:19 +02:00
parent 150b720f12
commit 635d25c74c
216 changed files with 23336 additions and 4077 deletions

View File

@@ -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}</>;
}
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>);
}

View 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>);
}

View File

@@ -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>);
}

View 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;
}

View 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;

View File

@@ -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>
);

View File

@@ -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;
}

View File

@@ -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>);
}

View File

@@ -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)} />
</>);
}

View File

@@ -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>
);

View 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>
);
}

View 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>
);
}

View File

@@ -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>);
}

View File

@@ -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>);
}

View File

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

View File

@@ -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>);
}

View File

@@ -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>);
}

View File

@@ -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);
}

View 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>);
}

View 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>
);
}

View File

@@ -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>;
}

View File

@@ -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>
);

View File

@@ -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]);
}

View File

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

View File

@@ -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>);
}

View File

@@ -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>);
}

View File

@@ -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(", ") : "—";
}
}

View File

@@ -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>);
}

View File

@@ -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
)}
</>
);
</>);
}

View File

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