feat: P2.1 extended signatures — multiple per identity + TipTap editor
- New stores/signature-store.ts: Zustand persist with CRUD, default/reply signature IDs, per-identity signature mapping - New signature-settings.tsx: list management with add/edit/delete/duplicate - New signature-editor-modal.tsx: TipTap rich text editor for signatures - email-composer.tsx: auto-insert signature based on mode (compose/reply) + signature selector dropdown in toolbar - identity-form.tsx: per-identity default/reply signature dropdowns - settings/page.tsx: Signatures tab in Mail settings group
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { SettingsSection, SettingItem, Select } from './settings-section';
|
||||
import { SignatureEditorModal } from './signature-editor-modal';
|
||||
import { useSignatureStore, type Signature } from '@/stores/signature-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { truncateText } from '@/lib/utils';
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Copy,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function SignatureSettings() {
|
||||
const t = useTranslations('signatures');
|
||||
const tCommon = useTranslations('common');
|
||||
const {
|
||||
signatures,
|
||||
defaultSignatureId,
|
||||
replySignatureId,
|
||||
identitySignatureMap,
|
||||
addSignature,
|
||||
updateSignature,
|
||||
deleteSignature,
|
||||
duplicateSignature,
|
||||
setDefaultSignatureId,
|
||||
setReplySignatureId,
|
||||
setIdentitySignature,
|
||||
} = useSignatureStore();
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
|
||||
const [editingSignature, setEditingSignature] = useState<Signature | null>(null);
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Signature | null>(null);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingSignature(null);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const handleEdit = (sig: Signature) => {
|
||||
setEditingSignature(sig);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const handleDuplicate = (id: string) => {
|
||||
duplicateSignature(id);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (deleteTarget) {
|
||||
deleteSignature(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = (data: { name: string; body: string; plainText: string }) => {
|
||||
if (editingSignature) {
|
||||
updateSignature(editingSignature.id, data);
|
||||
} else {
|
||||
addSignature(data);
|
||||
}
|
||||
setShowEditor(false);
|
||||
setEditingSignature(null);
|
||||
};
|
||||
|
||||
const signatureOptions = [
|
||||
{ value: '', label: t('no_signature') },
|
||||
...signatures.map((sig) => ({ value: sig.id, label: sig.name })),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<SettingItem
|
||||
label={t('default_signature.label')}
|
||||
description={t('default_signature.description')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={defaultSignatureId ?? ''}
|
||||
onChange={(value) => setDefaultSignatureId(value || null)}
|
||||
options={signatureOptions}
|
||||
ariaLabel={t('default_signature.label')}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('reply_signature.label')}
|
||||
description={t('reply_signature.description')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={replySignatureId ?? ''}
|
||||
onChange={(value) => setReplySignatureId(value || null)}
|
||||
options={signatureOptions}
|
||||
ariaLabel={t('reply_signature.label')}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{identities.length > 0 && (
|
||||
<SettingItem
|
||||
label={t('per_identity_signatures.label')}
|
||||
description={t('per_identity_signatures.description')}
|
||||
>
|
||||
<div className="space-y-2 max-w-xs">
|
||||
{identities.map((identity) => {
|
||||
const mapping = identitySignatureMap[identity.id] ?? {};
|
||||
const identitySigOptions = [
|
||||
{ value: '', label: t('use_global_default') },
|
||||
...signatures.map((sig) => ({ value: sig.id, label: sig.name })),
|
||||
];
|
||||
return (
|
||||
<div key={identity.id} className="border border-border rounded-md p-3 space-y-2">
|
||||
<span className="text-sm font-medium block truncate">
|
||||
{identity.name ? `${identity.name} <${identity.email}>` : identity.email}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-16 shrink-0">
|
||||
{t('default')}
|
||||
</span>
|
||||
<select
|
||||
value={mapping.defaultId ?? ''}
|
||||
onChange={(e) =>
|
||||
setIdentitySignature(identity.id, 'default', e.target.value || null)
|
||||
}
|
||||
className="flex-1 px-2 py-1 text-xs rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{identitySigOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-16 shrink-0">
|
||||
{t('reply')}
|
||||
</span>
|
||||
<select
|
||||
value={mapping.replyId ?? ''}
|
||||
onChange={(e) =>
|
||||
setIdentitySignature(identity.id, 'reply', e.target.value || null)
|
||||
}
|
||||
className="flex-1 px-2 py-1 text-xs rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{identitySigOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{t('your_signatures', { count: signatures.length })}
|
||||
</h4>
|
||||
<Button size="sm" onClick={handleAdd}>
|
||||
<Plus className="w-4 h-4 me-1" />
|
||||
{t('add_signature')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{signatures.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t('no_signatures')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-md divide-y divide-border">
|
||||
{signatures.map((sig) => (
|
||||
<div
|
||||
key={sig.id}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center gap-3 min-w-0 text-start"
|
||||
onClick={() => handleEdit(sig)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{sig.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{truncateText(sig.plainText, 80)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5 ml-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDuplicate(sig.id);
|
||||
}}
|
||||
title={t('duplicate')}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(sig);
|
||||
}}
|
||||
title={tCommon('edit')}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(sig);
|
||||
}}
|
||||
title={tCommon('delete')}
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{showEditor && (
|
||||
<SignatureEditorModal
|
||||
signature={editingSignature}
|
||||
onSave={handleSave}
|
||||
onClose={() => {
|
||||
setShowEditor(false);
|
||||
setEditingSignature(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title={t('delete_title')}
|
||||
message={t('delete_message', { name: deleteTarget?.name ?? '' })}
|
||||
variant="destructive"
|
||||
confirmText={tCommon('delete')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user