Merge branch 'bulwarkmail:main' into feature/scheduled-send
This commit is contained in:
@@ -10,6 +10,7 @@ import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useUpdateStore } from '@/stores/update-store';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { SpamSiegeGame } from './spam-siege-game';
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
@@ -123,12 +124,12 @@ export function AboutDataSettings() {
|
||||
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
|
||||
<div className="shrink-0">
|
||||
<img
|
||||
src="/branding/Bulwark_Logo_Color.svg"
|
||||
src={`${getPathPrefix()}/branding/Bulwark_Logo_Color.svg`}
|
||||
alt="Bulwark"
|
||||
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||
/>
|
||||
<img
|
||||
src="/branding/Bulwark_Logo_White.svg"
|
||||
src={`${getPathPrefix()}/branding/Bulwark_Logo_White.svg`}
|
||||
alt="Bulwark"
|
||||
className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -120,9 +120,10 @@ export function ReadingSettings() {
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select
|
||||
value={deleteAction}
|
||||
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'permanent')}
|
||||
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'trash-and-read' | 'permanent')}
|
||||
options={[
|
||||
{ value: 'trash', label: t('delete_action.trash') },
|
||||
{ value: 'trash-and-read', label: t('delete_action.trash_and_read') },
|
||||
{ value: 'permanent', label: t('delete_action.permanent') },
|
||||
]}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user