HIGH fixes (7): - H1: VNCdirectory admin i18n — 30+ translation keys added - H2: handleSave try/catch with error toast - H3: Free/busy accountId scoping - H4: cancelEventBookings filter by eventId - H5: Resource picker static apiFetch import - H6: Sharing-store toast messages via lastMessage state - H7: roleLabel for all resource types MEDIUM fixes (11): - M1: identitySignatureMap cleanup on delete - M2: Now-line relative positioning - M3: Radial menu disabled item keyboard nav - M4: Radial menu stable event listener via refs - M5: cancelBooking error on missing booking - M6: PasswordRow isMasked state flag - M7: Extract shared rights into lib/sharing-rights.ts - M8: VNCtalk client server-side guard - M9: Collabora configManager instead of process.env - M10: CONFIG_ENV_MAP VNCdirectory fields - M11: SENSITIVE_CONFIG_KEYS field name unification LOW fixes (7): - L1-L3: Unused imports removed - L4: aria-labels on close, clear, search, spinner - L5-L7: Comments for intentional patterns, null guard
400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { useEditor, EditorContent } from '@tiptap/react';
|
|
import StarterKit from '@tiptap/starter-kit';
|
|
import Paragraph from '@tiptap/extension-paragraph';
|
|
import Underline from '@tiptap/extension-underline';
|
|
import Link from '@tiptap/extension-link';
|
|
import TextAlign from '@tiptap/extension-text-align';
|
|
import { TextStyle } from '@tiptap/extension-text-style';
|
|
import Color from '@tiptap/extension-color';
|
|
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { cn } from '@/lib/utils';
|
|
import { htmlToPlainText } from '@/lib/html-to-text';
|
|
import type { Signature } from '@/stores/signature-store';
|
|
import {
|
|
Bold,
|
|
Italic,
|
|
Underline as UnderlineIcon,
|
|
Strikethrough,
|
|
List,
|
|
ListOrdered,
|
|
AlignLeft,
|
|
AlignCenter,
|
|
AlignRight,
|
|
Link as LinkIcon,
|
|
Baseline,
|
|
X,
|
|
} from 'lucide-react';
|
|
|
|
interface SignatureEditorModalProps {
|
|
signature?: Signature | null;
|
|
onSave: (data: { name: string; body: string; plainText: string }) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const StyledParagraph = Paragraph.extend({
|
|
addAttributes() {
|
|
return {
|
|
...this.parent?.(),
|
|
style: {
|
|
default: null as string | null,
|
|
parseHTML: (el: HTMLElement) => el.getAttribute('style'),
|
|
renderHTML: (attrs: Record<string, string | null>) =>
|
|
attrs.style ? { style: attrs.style } : {},
|
|
},
|
|
class: {
|
|
default: null as string | null,
|
|
parseHTML: (el: HTMLElement) => el.getAttribute('class'),
|
|
renderHTML: (attrs: Record<string, string | null>) =>
|
|
attrs.class ? { class: attrs.class } : {},
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
const TEXT_COLORS = [
|
|
'#000000', '#5f6368', '#9aa0a6', '#c5221f', '#e8710a', '#f9ab00', '#188038', '#1967d2',
|
|
'#7627bb', '#c2185b', '#795548', '#fa5252', '#fd7e14', '#40c057', '#4dabf7', '#e64980',
|
|
];
|
|
|
|
function ToolbarButton({
|
|
active,
|
|
onClick,
|
|
children,
|
|
title,
|
|
disabled,
|
|
}: {
|
|
active?: boolean;
|
|
onClick: () => void;
|
|
children: React.ReactNode;
|
|
title: string;
|
|
disabled?: boolean;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
title={title}
|
|
className={cn(
|
|
'p-1.5 rounded hover:bg-accent transition-colors',
|
|
active && 'bg-accent text-accent-foreground',
|
|
disabled && 'opacity-40 cursor-not-allowed'
|
|
)}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ToolbarSeparator() {
|
|
return <div className="w-px h-5 bg-border mx-0.5" />;
|
|
}
|
|
|
|
export function SignatureEditorModal({
|
|
signature,
|
|
onSave,
|
|
onClose,
|
|
}: SignatureEditorModalProps) {
|
|
const t = useTranslations('signatures');
|
|
const tCommon = useTranslations('common');
|
|
const isEditing = !!signature;
|
|
|
|
const [name, setName] = useState(signature?.name ?? '');
|
|
const [nameError, setNameError] = useState('');
|
|
const [showPreview, setShowPreview] = useState(false);
|
|
const [colorMenuOpen, setColorMenuOpen] = useState(false);
|
|
|
|
const dialogRef = useFocusTrap({
|
|
isActive: true,
|
|
onEscape: onClose,
|
|
restoreFocus: true,
|
|
});
|
|
|
|
const editor = useEditor({
|
|
extensions: [
|
|
StarterKit.configure({
|
|
heading: false,
|
|
paragraph: false,
|
|
link: false,
|
|
underline: false,
|
|
codeBlock: false,
|
|
}),
|
|
StyledParagraph,
|
|
Underline,
|
|
Link.configure({
|
|
openOnClick: false,
|
|
HTMLAttributes: { rel: 'noopener noreferrer nofollow' },
|
|
}),
|
|
TextAlign.configure({
|
|
types: ['paragraph'],
|
|
}),
|
|
TextStyle,
|
|
Color,
|
|
],
|
|
content: signature?.body ?? '<p></p>',
|
|
editorProps: {
|
|
attributes: {
|
|
class: 'tiptap min-h-[120px] px-3 py-2 text-sm text-foreground focus:outline-none',
|
|
},
|
|
},
|
|
immediatelyRender: false,
|
|
});
|
|
|
|
const addLink = useCallback(() => {
|
|
if (!editor) return;
|
|
const previousUrl = editor.getAttributes('link').href;
|
|
const url = window.prompt('URL', previousUrl);
|
|
if (url === null) return;
|
|
if (url === '') {
|
|
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
|
return;
|
|
}
|
|
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
}, [editor]);
|
|
|
|
const handleSave = () => {
|
|
const trimmedName = name.trim();
|
|
if (!trimmedName) {
|
|
setNameError(t('name_required'));
|
|
return;
|
|
}
|
|
const html = editor?.getHTML() ?? '<p></p>';
|
|
const plainText = htmlToPlainText(html);
|
|
onSave({ name: trimmedName, body: html, plainText });
|
|
};
|
|
|
|
const bodyHtml = editor?.getHTML() ?? '';
|
|
const bodyPlainText = htmlToPlainText(bodyHtml);
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-start justify-center z-[60] p-4 pt-[10vh] animate-in fade-in duration-150">
|
|
<div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-2xl animate-in zoom-in-95 duration-200"
|
|
>
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
|
<h2 className="text-lg font-semibold text-foreground">
|
|
{isEditing ? t('edit_signature') : t('new_signature')}
|
|
</h2>
|
|
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8" aria-label="Close">
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-4 max-h-[70vh] overflow-y-auto">
|
|
<div>
|
|
<label htmlFor="sig-name" className="block text-sm font-medium mb-1">
|
|
{t('name_label')}
|
|
</label>
|
|
<Input
|
|
id="sig-name"
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => {
|
|
setName(e.target.value);
|
|
if (nameError) setNameError('');
|
|
}}
|
|
placeholder={t('name_placeholder')}
|
|
className={cn(nameError && 'border-destructive')}
|
|
aria-invalid={!!nameError}
|
|
aria-describedby={nameError ? 'sig-name-error' : undefined}
|
|
/>
|
|
{nameError && (
|
|
<p id="sig-name-error" className="text-sm text-destructive mt-1" role="alert">
|
|
{nameError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-sm font-medium">{t('editor_label')}</span>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowPreview(!showPreview)}
|
|
className="h-7 text-xs"
|
|
>
|
|
{showPreview ? t('show_editor') : t('show_preview')}
|
|
</Button>
|
|
</div>
|
|
|
|
{showPreview ? (
|
|
<div className="border border-border rounded-md bg-muted/30 p-4 min-h-[200px]">
|
|
<div className="text-xs text-muted-foreground mb-2 font-medium">
|
|
{t('html_preview_label')}
|
|
</div>
|
|
<div
|
|
className="text-sm text-foreground [&_a]:text-primary [&_a]:underline-offset-2"
|
|
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
|
/>
|
|
<div className="mt-4 pt-4 border-t border-border">
|
|
<div className="text-xs text-muted-foreground mb-2 font-medium">
|
|
{t('plain_text_preview_label')}
|
|
</div>
|
|
<pre className="text-sm text-foreground whitespace-pre-wrap font-sans">
|
|
{bodyPlainText}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className={cn('flex flex-col border border-border rounded-md overflow-hidden')}>
|
|
<div className="flex flex-wrap items-center gap-0.5 px-3 py-1.5 border-b border-border/50 bg-muted/30">
|
|
<ToolbarButton
|
|
active={editor?.isActive('bold')}
|
|
onClick={() => editor?.chain().focus().toggleBold().run()}
|
|
title={t('toolbar.bold')}
|
|
>
|
|
<Bold className="w-4 h-4" />
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
active={editor?.isActive('italic')}
|
|
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
|
title={t('toolbar.italic')}
|
|
>
|
|
<Italic className="w-4 h-4" />
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
active={editor?.isActive('underline')}
|
|
onClick={() => editor?.chain().focus().toggleUnderline().run()}
|
|
title={t('toolbar.underline')}
|
|
>
|
|
<UnderlineIcon className="w-4 h-4" />
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
active={editor?.isActive('strike')}
|
|
onClick={() => editor?.chain().focus().toggleStrike().run()}
|
|
title={t('toolbar.strikethrough')}
|
|
>
|
|
<Strikethrough className="w-4 h-4" />
|
|
</ToolbarButton>
|
|
|
|
<div className="relative">
|
|
<ToolbarButton
|
|
active={!!editor?.getAttributes('textStyle').color}
|
|
onClick={() => setColorMenuOpen((v) => !v)}
|
|
title={t('toolbar.text_color')}
|
|
>
|
|
<Baseline
|
|
className="w-4 h-4"
|
|
style={{ color: editor?.getAttributes('textStyle').color || undefined }}
|
|
/>
|
|
</ToolbarButton>
|
|
{colorMenuOpen && (
|
|
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2">
|
|
<div
|
|
className="grid gap-0.5"
|
|
style={{ gridTemplateColumns: 'repeat(8, 1fr)' }}
|
|
>
|
|
{TEXT_COLORS.map((color) => (
|
|
<button
|
|
key={color}
|
|
type="button"
|
|
title={color}
|
|
onClick={() => {
|
|
editor?.chain().focus().setColor(color).run();
|
|
setColorMenuOpen(false);
|
|
}}
|
|
className={cn(
|
|
'w-4 h-4 border border-border/60 rounded-[2px] transition-transform hover:scale-110',
|
|
editor?.getAttributes('textStyle').color === color &&
|
|
'ring-1 ring-ring ring-offset-1'
|
|
)}
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="h-px bg-border my-1.5" />
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-2 px-2 py-1 text-sm rounded hover:bg-accent text-start w-full"
|
|
onClick={() => {
|
|
editor?.chain().focus().unsetColor().run();
|
|
setColorMenuOpen(false);
|
|
}}
|
|
>
|
|
{t('toolbar.remove_color')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<ToolbarSeparator />
|
|
|
|
<ToolbarButton
|
|
active={editor?.isActive('bulletList')}
|
|
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
|
title={t('toolbar.bullet_list')}
|
|
>
|
|
<List className="w-4 h-4" />
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
active={editor?.isActive('orderedList')}
|
|
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
|
title={t('toolbar.ordered_list')}
|
|
>
|
|
<ListOrdered className="w-4 h-4" />
|
|
</ToolbarButton>
|
|
|
|
<ToolbarSeparator />
|
|
|
|
<ToolbarButton
|
|
active={editor?.isActive({ textAlign: 'left' })}
|
|
onClick={() => editor?.chain().focus().setTextAlign('left').run()}
|
|
title={t('toolbar.align_left')}
|
|
>
|
|
<AlignLeft className="w-4 h-4" />
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
active={editor?.isActive({ textAlign: 'center' })}
|
|
onClick={() => editor?.chain().focus().setTextAlign('center').run()}
|
|
title={t('toolbar.align_center')}
|
|
>
|
|
<AlignCenter className="w-4 h-4" />
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
active={editor?.isActive({ textAlign: 'right' })}
|
|
onClick={() => editor?.chain().focus().setTextAlign('right').run()}
|
|
title={t('toolbar.align_right')}
|
|
>
|
|
<AlignRight className="w-4 h-4" />
|
|
</ToolbarButton>
|
|
|
|
<ToolbarSeparator />
|
|
|
|
<ToolbarButton
|
|
active={editor?.isActive('link')}
|
|
onClick={addLink}
|
|
title={t('toolbar.link')}
|
|
>
|
|
<LinkIcon className="w-4 h-4" />
|
|
</ToolbarButton>
|
|
</div>
|
|
<EditorContent editor={editor} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
|
<Button variant="outline" onClick={onClose}>
|
|
{tCommon('cancel')}
|
|
</Button>
|
|
<Button onClick={handleSave}>
|
|
{tCommon('save')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|