intermittent commit

This commit is contained in:
2026-07-14 13:22:10 +02:00
parent 8aa1943581
commit e6f7c45f0a
76 changed files with 6039 additions and 2188 deletions

View File

@@ -0,0 +1,103 @@
import type { ReactNode } from "react";
import FormField from "./FormField";
import PasswordField from "./PasswordField";
export type CredentialValues = {
username?: string | null;
password?: string | null;
};
export type CredentialFieldsProps = {
values: CredentialValues;
onChange: (patch: Partial<CredentialValues>) => void;
disabled?: boolean;
usernameDisabled?: boolean;
passwordDisabled?: boolean;
savedPassword?: boolean;
savedPasswordPlaceholder?: string;
heading?: ReactNode;
headingClassName?: string;
usernameLabel?: ReactNode;
passwordLabel?: ReactNode;
usernamePlaceholder?: string;
passwordPlaceholder?: string;
usernameAutoComplete?: string;
passwordAutoComplete?: string;
showUsername?: boolean;
showPassword?: boolean;
};
export type CredentialPanelProps = CredentialFieldsProps & {
className?: string;
gridClassName?: string;
children?: ReactNode;
};
export function CredentialFields({
values,
onChange,
disabled = false,
usernameDisabled = disabled,
passwordDisabled = disabled,
savedPassword = false,
savedPasswordPlaceholder = "••••••••",
heading,
headingClassName = "credential-panel-heading",
usernameLabel = "i18n:govoplan-core.username.84c29015",
passwordLabel = "i18n:govoplan-core.password.8be3c943",
usernamePlaceholder,
passwordPlaceholder,
usernameAutoComplete = "username",
passwordAutoComplete = "new-password",
showUsername = true,
showPassword = true
}: CredentialFieldsProps) {
return (
<>
{heading && <div className={headingClassName}>{heading}</div>}
{showUsername &&
<FormField label={usernameLabel}>
<input
value={stringValue(values.username)}
disabled={usernameDisabled}
autoComplete={usernameAutoComplete}
placeholder={usernamePlaceholder}
onChange={(event) => onChange({ username: event.target.value })} />
</FormField>
}
{showPassword &&
<FormField label={passwordLabel}>
<PasswordField
value={stringValue(values.password)}
disabled={passwordDisabled}
saved={savedPassword}
savedPlaceholder={savedPasswordPlaceholder}
placeholder={passwordPlaceholder}
autoComplete={passwordAutoComplete}
onValueChange={(password) => onChange({ password })} />
</FormField>
}
</>);
}
export default function CredentialPanel({
className = "",
gridClassName = "",
children,
...fieldProps
}: CredentialPanelProps) {
return (
<div className={`credential-panel ${className}`.trim()}>
<div className={`credential-panel-grid ${gridClassName}`.trim()}>
<CredentialFields {...fieldProps} />
</div>
{children && <div className="credential-panel-extra">{children}</div>}
</div>);
}
function stringValue(value: string | number | null | undefined): string {
if (value === null || value === undefined) return "";
return String(value);
}

View File

@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
import HoverTooltip from "./HoverTooltip";
export type DisabledActionTooltipProps = {
reason?: ReactNode;
children: ReactNode;
className?: string;
};
export default function DisabledActionTooltip({ reason, children, className = "" }: DisabledActionTooltipProps) {
if (!reason) return <>{children}</>;
return (
<HoverTooltip
content={reason}
tone="danger"
className={`disabled-action-tooltip ${className}`.trim()}
triggerTabIndex={0}>
{children}
</HoverTooltip>
);
}

View File

@@ -0,0 +1,228 @@
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";
export type HoverTooltipTone = "default" | "danger";
type TooltipPosition = {
top: number;
left: number;
arrowLeft: number;
placement: "top" | "bottom";
};
export type HoverTooltipProps = {
content: ReactNode;
children: ReactNode;
className?: string;
tone?: HoverTooltipTone;
ariaLabel?: string;
triggerTabIndex?: number;
openDelayMs?: number;
};
const VIEWPORT_MARGIN = 12;
const TRIGGER_GAP = 10;
const BASE_TOOLTIP_STYLE: CSSProperties = {
position: "fixed",
zIndex: 20000,
width: "max-content",
maxWidth: "min(320px, calc(100vw - 48px))",
borderRadius: 7,
boxShadow: "var(--shadow-popover)",
fontSize: 12,
lineHeight: 1.4,
padding: "9px 10px",
whiteSpace: "normal",
pointerEvents: "none"
};
function clamp(value: number, min: number, max: number) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
function tooltipStyleForTone(tone: HoverTooltipTone): CSSProperties {
if (tone === "danger") {
return {
border: "1px solid var(--border-danger-soft)",
background: "var(--danger-muted-bg)",
color: "var(--danger-text-tooltip)",
fontWeight: 700
};
}
return {
border: "1px solid var(--line-dark)",
background: "var(--surface)",
color: "var(--text)",
fontWeight: 500
};
}
function arrowStyleForTone(tone: HoverTooltipTone): Pick<CSSProperties, "borderColor" | "background"> {
if (tone === "danger") {
return {
borderColor: "var(--border-danger-soft)",
background: "var(--danger-muted-bg)"
};
}
return {
borderColor: "var(--line-dark)",
background: "var(--surface)"
};
}
export default function HoverTooltip({
content,
children,
className = "",
tone = "default",
ariaLabel,
triggerTabIndex,
openDelayMs = 350
}: HoverTooltipProps) {
const { translateText } = usePlatformLanguage();
const tooltipId = useId();
const triggerRef = useRef<HTMLSpanElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const openTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const clearOpenTimer = useCallback(() => {
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}, []);
const openWithDelay = useCallback(() => {
if (!content) return;
clearOpenTimer();
openTimerRef.current = window.setTimeout(() => {
openTimerRef.current = null;
setIsOpen(true);
}, openDelayMs);
}, [clearOpenTimer, content, openDelayMs]);
const close = useCallback(() => {
clearOpenTimer();
setIsOpen(false);
}, [clearOpenTimer]);
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
const tooltip = tooltipRef.current;
if (!trigger || !tooltip) return;
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
const preferredLeft = triggerCenterX - tooltipRect.width / 2;
const left = clamp(preferredLeft, VIEWPORT_MARGIN, window.innerWidth - tooltipRect.width - VIEWPORT_MARGIN);
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);
setPosition({
top,
left,
arrowLeft: clamp(triggerCenterX - left, 12, tooltipRect.width - 12),
placement: hasRoomAbove ? "top" : "bottom"
});
}, []);
useLayoutEffect(() => {
if (!isOpen) {
setPosition(null);
return;
}
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
return () => window.cancelAnimationFrame(frame);
}, [isOpen, updatePosition]);
useEffect(() => {
if (!isOpen) return undefined;
const handleScrollOrResize = () => updatePosition();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
window.addEventListener("scroll", handleScrollOrResize, true);
window.addEventListener("resize", handleScrollOrResize);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("scroll", handleScrollOrResize, true);
window.removeEventListener("resize", handleScrollOrResize);
window.removeEventListener("keydown", handleKeyDown);
};
}, [close, isOpen, updatePosition]);
useEffect(() => clearOpenTimer, [clearOpenTimer]);
const translatedAriaLabel = ariaLabel ? translateText(ariaLabel) : undefined;
const tooltipStyle: CSSProperties = {
...BASE_TOOLTIP_STYLE,
...tooltipStyleForTone(tone),
top: position?.top ?? -9999,
left: position?.left ?? -9999,
opacity: position ? 1 : 0
};
const arrowColors = arrowStyleForTone(tone);
const arrowStyle: CSSProperties = position?.placement === "bottom"
? {
position: "absolute",
left: position.arrowLeft,
top: 0,
width: 9,
height: 9,
borderLeft: "1px solid",
borderTop: "1px solid",
...arrowColors,
transform: "translate(-50%, -5px) rotate(45deg)"
}
: {
position: "absolute",
left: position?.arrowLeft ?? 16,
top: "100%",
width: 9,
height: 9,
borderRight: "1px solid",
borderBottom: "1px solid",
...arrowColors,
transform: "translate(-50%, -5px) rotate(45deg)"
};
return (
<>
<span
ref={triggerRef}
className={className}
tabIndex={triggerTabIndex}
aria-label={translatedAriaLabel}
aria-describedby={isOpen ? tooltipId : undefined}
onMouseEnter={openWithDelay}
onMouseLeave={close}
onFocus={openWithDelay}
onBlur={close}>
{children}
</span>
{isOpen && typeof document !== "undefined" && createPortal(
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
{typeof content === "string" ? translateText(content) : content}
<span aria-hidden="true" style={arrowStyle} />
</div>,
document.body
)}
</>
);
}

View File

@@ -1,4 +1,5 @@
import { i18nMessage } from "../../i18n/LanguageContext";import { useEffect, useId, useMemo, useRef, useState } from "react";
import { i18nMessage, usePlatformLanguage } 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";
@@ -15,6 +16,7 @@ type EmailAddressInputProps = {
value: MailboxAddress[];
onChange?: (value: MailboxAddress[]) => void;
onAddressAdded?: (address: MailboxAddress) => void;
onSuggestionQueryChange?: (query: string) => void;
suggestions?: MailboxAddress[];
allowMultiple?: boolean;
clearOnAdd?: boolean;
@@ -31,6 +33,7 @@ export default function EmailAddressInput({
value,
onChange,
onAddressAdded,
onSuggestionQueryChange,
suggestions = [],
allowMultiple = true,
clearOnAdd = false,
@@ -42,6 +45,7 @@ export default function EmailAddressInput({
compact = false,
showAddButton
}: EmailAddressInputProps) {
const { translateText } = usePlatformLanguage();
const inputId = useId();
const normalizedValue = useMemo(() => dedupeAddresses(value), [value]);
const normalizedSuggestions = useMemo(() => dedupeAddresses(suggestions), [suggestions]);
@@ -51,9 +55,18 @@ export default function EmailAddressInput({
const [dialogEmail, setDialogEmail] = useState("");
const [error, setError] = useState("");
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
const lastSuggestionQueryRef = useRef<string | null>(null);
const addButtonRef = useRef<HTMLButtonElement | null>(null);
const canUseAddButton = showAddButton ?? allowMultiple;
useEffect(() => {
const query = entryText.trim();
if (query === "" && lastSuggestionQueryRef.current === null) return;
if (query === lastSuggestionQueryRef.current) return;
lastSuggestionQueryRef.current = query;
onSuggestionQueryChange?.(query);
}, [entryText, onSuggestionQueryChange]);
const filteredSuggestions = useMemo(() => {
const query = entryText.trim().toLowerCase();
if (!query) return normalizedSuggestions.slice(0, 6);
@@ -168,18 +181,18 @@ export default function EmailAddressInput({
if (event.key === "Escape") setDialogOpen(false);
}}>
<h4 id={`${inputId}-dialog-title`}>i18n:govoplan-core.add_address.a71075c4</h4>
<h4 id={`${inputId}-dialog-title`}>{translateText("i18n:govoplan-core.add_address.a71075c4")}</h4>
<label>
<span>i18n:govoplan-core.name.709a2322</span>
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={namePlaceholder} autoFocus />
<span>{translateText("i18n:govoplan-core.name.709a2322")}</span>
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={translateText(namePlaceholder)} autoFocus />
</label>
<label>
<span>i18n:govoplan-core.email_address.c94d3175</span>
<span>{translateText("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)}>i18n:govoplan-core.cancel.77dfd213</Button>
<Button type="button" variant="primary" onClick={commitDialogAddress}>{addLabel}</Button>
<Button type="button" onClick={() => setDialogOpen(false)}>{translateText("i18n:govoplan-core.cancel.77dfd213")}</Button>
<Button type="button" variant="primary" onClick={commitDialogAddress}>{translateText(addLabel)}</Button>
</div>
</div>,
document.body
@@ -189,11 +202,11 @@ export default function EmailAddressInput({
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}>
<div className={`email-address-editor ${error ? "has-error" : ""}`}>
<div className="email-chip-list" aria-live="polite">
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{emptyText}</span>}
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{translateText(emptyText)}</span>}
{normalizedValue.map((address) => {
const valid = isValidEmailAddress(address.email);
return (
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : "i18n:govoplan-core.invalid_email_address.9e4ee6d7"}>
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : translateText("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 &&
@@ -217,11 +230,11 @@ export default function EmailAddressInput({
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" />
placeholder={translateText(i18nMessage("i18n:govoplan-core.value_value.27085f09", { value0: translateText(namePlaceholder), value1: emailPlaceholder }))}
aria-label={translateText("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 ref={addButtonRef} type="button" className="email-address-plus" aria-label={translateText("i18n:govoplan-core.open_address_form.f8ee560f")} title={translateText("i18n:govoplan-core.add_address_with_form.13b3b3e7")} onClick={() => setDialogOpen((open) => !open)}>
+
</button>
}
@@ -230,7 +243,7 @@ export default function EmailAddressInput({
</div>
{!disabled && filteredSuggestions.length > 0 && entryText.trim() &&
<div className="email-address-suggestions" role="listbox" aria-label="i18n:govoplan-core.address_suggestions.45ba4a20">
<div className="email-address-suggestions" role="listbox" aria-label={translateText("i18n:govoplan-core.address_suggestions.45ba4a20")}>
{filteredSuggestions.map((item) =>
<button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option">
<span>{addressDisplayName(item)}</span>
@@ -239,7 +252,7 @@ export default function EmailAddressInput({
)}
</div>
}
{error && <p className="form-help danger-text">{error}</p>}
{error && <p className="form-help danger-text">{translateText(error)}</p>}
{addressDialog}
</div>);

View File

@@ -1,188 +1,20 @@
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";
import type { ReactNode } from "react";
import HoverTooltip from "../HoverTooltip";
type InlineHelpProps = {
children: ReactNode;
className?: string;
};
type TooltipPosition = {
top: number;
left: number;
arrowLeft: number;
placement: "top" | "bottom";
};
const OPEN_DELAY_MS = 350;
const VIEWPORT_MARGIN = 12;
const TRIGGER_GAP = 10;
const tooltipBaseStyle: CSSProperties = {
position: "fixed",
zIndex: 20000,
width: "max-content",
maxWidth: "min(320px, calc(100vw - 48px))",
border: "1px solid var(--line-dark)",
borderRadius: 7,
background: "var(--surface)",
boxShadow: "var(--shadow-popover)",
color: "var(--text)",
fontSize: 12,
fontWeight: 500,
lineHeight: 1.4,
padding: "9px 10px",
whiteSpace: "normal",
pointerEvents: "none"
};
function clamp(value: number, min: number, max: number) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
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);
const openTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const clearOpenTimer = useCallback(() => {
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}, []);
const openWithDelay = useCallback(() => {
clearOpenTimer();
openTimerRef.current = window.setTimeout(() => {
openTimerRef.current = null;
setIsOpen(true);
}, OPEN_DELAY_MS);
}, [clearOpenTimer]);
const close = useCallback(() => {
clearOpenTimer();
setIsOpen(false);
}, [clearOpenTimer]);
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
const tooltip = tooltipRef.current;
if (!trigger || !tooltip) return;
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
const preferredLeft = triggerCenterX - tooltipRect.width / 2;
const left = clamp(preferredLeft, VIEWPORT_MARGIN, window.innerWidth - tooltipRect.width - VIEWPORT_MARGIN);
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);
setPosition({
top,
left,
arrowLeft: clamp(triggerCenterX - left, 12, tooltipRect.width - 12),
placement: hasRoomAbove ? "top" : "bottom"
});
}, []);
useLayoutEffect(() => {
if (!isOpen) {
setPosition(null);
return;
}
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
return () => window.cancelAnimationFrame(frame);
}, [isOpen, updatePosition]);
useEffect(() => {
if (!isOpen) return undefined;
const handleScrollOrResize = () => updatePosition();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
window.addEventListener("scroll", handleScrollOrResize, true);
window.addEventListener("resize", handleScrollOrResize);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("scroll", handleScrollOrResize, true);
window.removeEventListener("resize", handleScrollOrResize);
window.removeEventListener("keydown", handleKeyDown);
};
}, [close, isOpen, updatePosition]);
useEffect(() => clearOpenTimer, [clearOpenTimer]);
if (!children) return null;
const tooltipStyle: CSSProperties = {
...tooltipBaseStyle,
top: position?.top ?? -9999,
left: position?.left ?? -9999,
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)"
};
return (
<>
<span
ref={triggerRef}
className={`inline-help ${className}`.trim()}
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}>
<span className="inline-help-mark" aria-hidden="true">?</span>
</span>
{isOpen && createPortal(
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
{typeof children === "string" ? translateText(children) : children}
<span aria-hidden="true" style={arrowStyle} />
</div>,
document.body
)}
</>);
<HoverTooltip
content={children}
className={`inline-help ${className}`.trim()}
ariaLabel="i18n:govoplan-core.show_field_help.e3dfe98f"
triggerTabIndex={-1}>
<span className="inline-help-mark" aria-hidden="true">?</span>
</HoverTooltip>
);
}

View File

@@ -1,8 +1,8 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import Button from "../Button";
import { CredentialFields } from "../CredentialPanel";
import DismissibleAlert from "../DismissibleAlert";
import FormField from "../FormField";
import PasswordField from "../PasswordField";
import SegmentedControl from "../SegmentedControl";
import ToggleSwitch from "../ToggleSwitch";
@@ -48,6 +48,9 @@ export type MailServerFolderLookupResult = {
details?: Record<string, unknown> | null;
};
export type MailServerSettingsSection = "smtp" | "imap" | "advanced";
export type MailServerSettingsMode = "all" | "server" | "credentials";
export type MailServerSettingsPanelProps = {
smtp: MailServerSmtpSettings;
imap: MailServerImapSettings;
@@ -97,12 +100,13 @@ export type MailServerSettingsPanelProps = {
className?: string;
floatingResults?: boolean;
initialSection?: MailServerSettingsSection;
visibleSections?: readonly MailServerSettingsSection[];
mode?: MailServerSettingsMode;
};
export const mailServerSecurityOptions = ["plain", "tls", "starttls"] as const;
export type MailServerSecurityOption = typeof mailServerSecurityOptions[number];
const securityOptions = mailServerSecurityOptions;
type MailServerSettingsSection = "smtp" | "imap" | "advanced";
export function defaultSmtpPort(security: MailServerSecurity | null | undefined): number {
if (security === "tls") return 465;
@@ -234,7 +238,9 @@ export default function MailServerSettingsPanel({
disabled = false,
className = "",
floatingResults = false,
initialSection = "smtp"
initialSection = "smtp",
visibleSections,
mode = "all"
}: MailServerSettingsPanelProps) {
const smtpFieldsDisabled = disabled || smtpDisabled;
const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled;
@@ -254,11 +260,29 @@ export default function MailServerSettingsPanel({
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;}[] = [
const allSections: {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" }];
const visibleSectionSet = new Set(visibleSections ?? allSections.map((section) => section.id));
const sections = allSections.filter((section) => visibleSectionSet.has(section.id));
const fallbackSection = sections[0]?.id ?? "smtp";
const resolvedInitialSection = sections.some((section) => section.id === initialSection) ? initialSection : fallbackSection;
const sectionKey = sections.map((section) => section.id).join("|");
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(resolvedInitialSection);
const showSectionSwitcher = sections.length > 1;
const showServerFields = mode !== "credentials";
const showCredentialFields = mode !== "server";
useEffect(() => {
if (!sections.some((section) => section.id === activeSection)) {
setActiveSection(resolvedInitialSection);
return;
}
if (initialSection !== activeSection && sections.some((section) => section.id === initialSection)) {
setActiveSection(initialSection);
}
}, [activeSection, initialSection, resolvedInitialSection, sectionKey]);
function patchSmtpSecurity(security: MailServerSecurity) {
@@ -283,6 +307,7 @@ export default function MailServerSettingsPanel({
return (
<div className={`mail-server-settings-panel ${className}`.trim()}>
{showSectionSwitcher &&
<SegmentedControl
className="mail-server-segmented-control"
size="equal"
@@ -291,27 +316,30 @@ export default function MailServerSettingsPanel({
onChange={setActiveSection}
options={sections}
/>
}
<div className="mail-server-settings-view">
{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">
{showServerFields &&
<>
<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)}
</>
}
{showCredentialFields &&
<CredentialFields
values={smtpCredentialValues}
onChange={patchSmtpCredentials}
disabled={smtpCredentialFieldsDisabled}
saved={smtpPasswordSaved}
savedPlaceholder={smtpSavedPasswordPlaceholder}
autoComplete="new-password"
onValueChange={(password) => patchSmtpCredentials({ password })} />
</FormField>
savedPassword={smtpPasswordSaved}
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder}
heading="i18n:govoplan-core.credentials.dd097a22"
headingClassName="mail-server-field-heading" />
}
</div>
{onTestSmtp &&
<div className="button-row compact-actions mail-server-actions">
@@ -325,22 +353,24 @@ export default function MailServerSettingsPanel({
{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">
{showServerFields &&
<>
<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)}
</>
}
{showCredentialFields &&
<CredentialFields
values={imapCredentialValues}
onChange={patchImapCredentials}
disabled={imapCredentialFieldsDisabled}
saved={imapPasswordSaved}
savedPlaceholder={imapSavedPasswordPlaceholder}
autoComplete="new-password"
onValueChange={(password) => patchImapCredentials({ password })} />
</FormField>
savedPassword={imapPasswordSaved}
savedPasswordPlaceholder={imapSavedPasswordPlaceholder}
heading="i18n:govoplan-core.credentials.dd097a22"
headingClassName="mail-server-field-heading" />
}
</div>
{onTestImap &&
<div className="button-row compact-actions mail-server-actions">

View File

@@ -150,7 +150,7 @@ type ColumnResizeState = {
behavior: DataGridResizeBehavior;
};
const STORAGE_PREFIX = "multimailer.datagrid.";
const STORAGE_PREFIX = "govoplan.datagrid.";
const FILTER_POPOVER_WIDTH = 320;
const FILTER_POPOVER_MARGIN = 12;
const MIN_HEADER_LABEL_WIDTH = 72;