Files
SRCmail/components/settings/downloads-settings.tsx
T

267 lines
9.4 KiB
TypeScript

"use client";
import { useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { SettingsSection, SettingItem, Select, ToggleSwitch } from "./settings-section";
import { RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
import {
DEFAULT_ATTACHMENT_TEMPLATE,
DEFAULT_BUNDLE_TEMPLATE,
DEFAULT_EMAIL_TEMPLATE,
EMAIL_TOKENS,
ATTACHMENT_TOKENS,
BUNDLE_TOKENS,
bundleExportFilename,
emailExportFilename,
attachmentDownloadFilename,
buildSampleEmail,
type EmailFilenameOptions,
} from "@/lib/download-filename";
function insertTokenAtCursor(
input: HTMLInputElement,
token: string,
current: string,
onChange: (next: string) => void,
): void {
const start = input.selectionStart ?? current.length;
const end = input.selectionEnd ?? current.length;
const before = current.slice(0, start);
const after = current.slice(end);
const insertion = `{${token}}`;
const next = `${before}${insertion}${after}`;
onChange(next);
// Restore focus and place caret after the inserted token.
requestAnimationFrame(() => {
input.focus();
const caret = before.length + insertion.length;
input.setSelectionRange(caret, caret);
});
}
interface TemplateEditorProps {
label: string;
description: string;
value: string;
defaultValue: string;
tokens: { token: string; description: string }[];
preview: string;
onChange: (next: string) => void;
resetLabel: string;
previewLabel: string;
placeholder?: string;
}
function TemplateEditor({
label,
description,
value,
defaultValue,
tokens,
preview,
onChange,
resetLabel,
previewLabel,
placeholder,
}: TemplateEditorProps) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div data-search-label={label} className="space-y-3 py-3 border-b border-border last:border-0">
<div>
<label className="text-sm font-medium text-foreground">{label}</label>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-stretch gap-2">
<input
ref={inputRef}
type="text"
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
className="flex-1 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground font-mono focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150"
/>
<button
type="button"
onClick={() => onChange(defaultValue)}
disabled={value === defaultValue}
title={resetLabel}
className={cn(
"px-2 rounded-md border border-border text-foreground transition-colors duration-150",
value === defaultValue
? "opacity-40 cursor-not-allowed"
: "hover:bg-muted cursor-pointer",
)}
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{tokens.map((t) => (
<button
key={t.token}
type="button"
title={t.description}
onClick={() => {
const input = inputRef.current;
if (!input) {
onChange(`${value}{${t.token}}`);
return;
}
insertTokenAtCursor(input, t.token, value, onChange);
}}
className="px-2 py-0.5 text-xs font-mono rounded bg-muted hover:bg-accent border border-border text-foreground transition-colors duration-150 cursor-pointer"
>
{`{${t.token}}`}
</button>
))}
</div>
<div className="text-xs text-muted-foreground">
<span className="opacity-70">{previewLabel} </span>
<span className="font-mono text-foreground/90 break-all">{preview}</span>
</div>
</div>
</div>
);
}
export function DownloadsSettings() {
const t = useTranslations("settings.downloads");
const {
emailDownloadTemplate,
attachmentDownloadTemplate,
bundleDownloadTemplate,
filenameSpaceReplacement,
filenameLowercase,
filenameStripDiacritics,
filenameCollapseSeparators,
postExportAction,
updateSetting,
} = useSettingsStore();
const sampleEmail = useMemo(() => buildSampleEmail(), []);
const sampleAttachment = useMemo(() => ({ name: "Invoice-2026-05.pdf", type: "application/pdf" }), []);
const transform = useMemo(
() => ({
spaceReplacement: filenameSpaceReplacement,
lowercase: filenameLowercase,
stripDiacritics: filenameStripDiacritics,
collapseSeparators: filenameCollapseSeparators,
}),
[filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators],
);
const emailOptions: EmailFilenameOptions = useMemo(
() => ({ ...transform, template: emailDownloadTemplate || DEFAULT_EMAIL_TEMPLATE }),
[transform, emailDownloadTemplate],
);
const attachmentOptions: EmailFilenameOptions = useMemo(
() => ({ ...transform, template: attachmentDownloadTemplate || DEFAULT_ATTACHMENT_TEMPLATE }),
[transform, attachmentDownloadTemplate],
);
const bundleOptions: EmailFilenameOptions = useMemo(
() => ({ ...transform, template: bundleDownloadTemplate || DEFAULT_BUNDLE_TEMPLATE }),
[transform, bundleDownloadTemplate],
);
const emlPreview = useMemo(
() => emailExportFilename(sampleEmail, emailOptions),
[sampleEmail, emailOptions],
);
const attachmentPreview = useMemo(
() => attachmentDownloadFilename(sampleEmail, sampleAttachment, attachmentOptions),
[sampleEmail, sampleAttachment, attachmentOptions],
);
const bundlePreview = useMemo(
// Render with the email's fixed sample date so the preview is stable as the
// user types in the template field.
() => bundleExportFilename(3, bundleOptions, sampleEmail.receivedAt ?? undefined),
[bundleOptions, sampleEmail],
);
return (
<SettingsSection title={t("title")} description={t("description")}>
<TemplateEditor
label={t("email_template.label")}
description={t("email_template.description")}
value={emailDownloadTemplate}
defaultValue={DEFAULT_EMAIL_TEMPLATE}
tokens={EMAIL_TOKENS}
preview={emlPreview}
onChange={(next) => updateSetting("emailDownloadTemplate", next)}
resetLabel={t("reset")}
previewLabel={t("preview")}
placeholder={DEFAULT_EMAIL_TEMPLATE}
/>
<TemplateEditor
label={t("attachment_template.label")}
description={t("attachment_template.description")}
value={attachmentDownloadTemplate}
defaultValue={DEFAULT_ATTACHMENT_TEMPLATE}
tokens={ATTACHMENT_TOKENS}
preview={attachmentPreview}
onChange={(next) => updateSetting("attachmentDownloadTemplate", next)}
resetLabel={t("reset")}
previewLabel={t("preview")}
placeholder={DEFAULT_ATTACHMENT_TEMPLATE}
/>
<TemplateEditor
label={t("bundle_template.label")}
description={t("bundle_template.description")}
value={bundleDownloadTemplate}
defaultValue={DEFAULT_BUNDLE_TEMPLATE}
tokens={BUNDLE_TOKENS}
preview={bundlePreview}
onChange={(next) => updateSetting("bundleDownloadTemplate", next)}
resetLabel={t("reset")}
previewLabel={t("preview")}
placeholder={DEFAULT_BUNDLE_TEMPLATE}
/>
<SettingItem label={t("spaces.label")} description={t("spaces.description")}>
<Select
value={filenameSpaceReplacement}
onChange={(value) => updateSetting("filenameSpaceReplacement", value as "keep" | "underscore" | "dash")}
options={[
{ value: "keep", label: t("spaces.keep") },
{ value: "underscore", label: t("spaces.underscore") },
{ value: "dash", label: t("spaces.dash") },
]}
/>
</SettingItem>
<SettingItem label={t("lowercase.label")} description={t("lowercase.description")}>
<ToggleSwitch
checked={filenameLowercase}
onChange={(checked) => updateSetting("filenameLowercase", checked)}
/>
</SettingItem>
<SettingItem label={t("strip_diacritics.label")} description={t("strip_diacritics.description")}>
<ToggleSwitch
checked={filenameStripDiacritics}
onChange={(checked) => updateSetting("filenameStripDiacritics", checked)}
/>
</SettingItem>
<SettingItem label={t("collapse_separators.label")} description={t("collapse_separators.description")}>
<ToggleSwitch
checked={filenameCollapseSeparators}
onChange={(checked) => updateSetting("filenameCollapseSeparators", checked)}
/>
</SettingItem>
<SettingItem label={t("after_export.label")} description={t("after_export.description")}>
<Select
value={postExportAction}
onChange={(value) => updateSetting("postExportAction", value as "keep" | "archive" | "trash")}
options={[
{ value: "keep", label: t("after_export.keep") },
{ value: "archive", label: t("after_export.archive") },
{ value: "trash", label: t("after_export.trash") },
]}
/>
</SettingItem>
</SettingsSection>
);
}