Enhance certificate designer and seafarer registration features with image support and dynamic department/rank options

This commit is contained in:
Nati
2026-08-24 14:15:55 +00:00
parent 4a3ad7c2e6
commit bd65fc0eab
16 changed files with 320 additions and 115 deletions

View File

@@ -1,5 +1,6 @@
import {
ActionIcon,
Button,
ColorInput,
Group,
NumberInput,
@@ -11,7 +12,7 @@ import {
TextInput,
Tooltip,
} from '@mantine/core';
import { IconTrash } from '@tabler/icons-react';
import { IconBold, IconItalic, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { TemplateFieldPlacement, TemplateVariable } from '@ema-platform/api';
@@ -43,7 +44,17 @@ export function BlockPropertiesPanel({
);
}
const isLiteral = block.variable === null;
const current = block;
const isLiteral = current.variable === null;
const isImage = current.type === 'image';
const variableByKey = new Map(variables.map((v) => [v.key, v]));
const selectVariable = (key: string | null) => {
if (!key) return;
const kind: 'text' | 'image' =
variableByKey.get(key)?.kind === 'image' ? 'image' : 'text';
onChange({ ...current, variable: key, type: kind });
};
return (
<Paper withBorder p="md" radius="md">
@@ -70,11 +81,9 @@ export function BlockPropertiesPanel({
value={isLiteral ? 'text' : 'variable'}
disabled={disabled}
onChange={(value) =>
onChange(
value === 'text'
? { ...block, variable: null, text: block.text ?? '' }
: { ...block, variable: variables[0]?.key ?? 'companyName' },
)
value === 'text'
? onChange({ ...block, variable: null, type: 'text', text: block.text ?? '' })
: selectVariable(variables[0]?.key ?? 'companyName')
}
data={[
{ value: 'variable', label: t('designer.blockVariable', 'Variable') },
@@ -94,24 +103,26 @@ export function BlockPropertiesPanel({
label={t('designer.blockVariableLabel', 'Variable')}
data={variables.map((variable) => ({
value: variable.key,
label: variable.label,
label: variable.kind === 'image' ? `🖼 ${variable.label}` : variable.label,
}))}
value={block.variable}
onChange={(value) => onChange({ ...block, variable: value })}
onChange={selectVariable}
searchable
disabled={disabled}
/>
)}
<Group gap="xs" grow>
<NumberInput
label={t('designer.blockFontSize', 'Font size')}
value={block.fontSize ?? 14}
onChange={(value) => onChange({ ...block, fontSize: Number(value) || 14 })}
min={4}
max={200}
disabled={disabled}
/>
{!isImage && (
<NumberInput
label={t('designer.blockFontSize', 'Font size')}
value={block.fontSize ?? 14}
onChange={(value) => onChange({ ...block, fontSize: Number(value) || 14 })}
min={4}
max={200}
disabled={disabled}
/>
)}
<NumberInput
label={t('designer.blockWidth', 'Width (%)')}
value={block.widthPct}
@@ -149,42 +160,62 @@ export function BlockPropertiesPanel({
/>
</Group>
<SegmentedControl
fullWidth
size="xs"
value={block.align ?? 'left'}
disabled={disabled}
onChange={(value) =>
onChange({ ...block, align: value as TemplateFieldPlacement['align'] })
}
data={[
{ value: 'left', label: t('designer.alignLeft', 'Left') },
{ value: 'center', label: t('designer.alignCenter', 'Centre') },
{ value: 'right', label: t('designer.alignRight', 'Right') },
]}
/>
{!isImage && (
<>
<SegmentedControl
fullWidth
size="xs"
value={block.align ?? 'left'}
disabled={disabled}
onChange={(value) =>
onChange({ ...block, align: value as TemplateFieldPlacement['align'] })
}
data={[
{ value: 'left', label: t('designer.alignLeft', 'Left') },
{ value: 'center', label: t('designer.alignCenter', 'Centre') },
{ value: 'right', label: t('designer.alignRight', 'Right') },
{ value: 'justify', label: t('designer.alignJustify', 'Justify') },
]}
/>
<SegmentedControl
fullWidth
size="xs"
value={block.fontWeight ?? 'normal'}
disabled={disabled}
onChange={(value) =>
onChange({ ...block, fontWeight: value as TemplateFieldPlacement['fontWeight'] })
}
data={[
{ value: 'normal', label: t('designer.weightNormal', 'Normal') },
{ value: 'bold', label: t('designer.weightBold', 'Bold') },
]}
/>
<Group gap="xs">
<Button
size="xs"
variant={block.fontWeight === 'bold' ? 'filled' : 'default'}
disabled={disabled}
onClick={() =>
onChange({
...block,
fontWeight: block.fontWeight === 'bold' ? 'normal' : 'bold',
})
}
>
<IconBold size={14} />
</Button>
<Button
size="xs"
variant={block.fontStyle === 'italic' ? 'filled' : 'default'}
disabled={disabled}
onClick={() =>
onChange({
...block,
fontStyle: block.fontStyle === 'italic' ? 'normal' : 'italic',
})
}
>
<IconItalic size={14} />
</Button>
</Group>
<ColorInput
label={t('designer.blockColor', 'Colour')}
value={block.color ?? '#111111'}
onChange={(value) => onChange({ ...block, color: value })}
disabled={disabled}
format="hex"
/>
<ColorInput
label={t('designer.blockColor', 'Colour')}
value={block.color ?? '#111111'}
onChange={(value) => onChange({ ...block, color: value })}
disabled={disabled}
format="hex"
/>
</>
)}
</Stack>
</Paper>
);

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Paper, Text } from '@mantine/core';
import { IconPhoto } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api';
@@ -233,6 +234,7 @@ export function TemplateCanvas({
{placements.map((block) => {
const isSelected = block.id === selectedId;
const isImage = block.type === 'image';
return (
<div
key={block.id}
@@ -247,6 +249,7 @@ export function TemplateCanvas({
width: `${block.widthPct}%`,
fontSize: block.fontSize ?? 14,
fontWeight: block.fontWeight ?? 'normal',
fontStyle: block.fontStyle ?? 'normal',
textAlign: block.align ?? 'left',
color: block.color ?? '#111111',
cursor: disabled ? 'default' : 'move',
@@ -259,9 +262,30 @@ export function TemplateCanvas({
lineHeight: 1.3,
wordWrap: 'break-word',
userSelect: 'none',
// An image block has no real image to show here — the actual
// data URI is only resolved server-side at render/preview
// time — so it gets a fixed square footprint and an icon
// instead of stretching to a text block's shape.
...(isImage
? {
aspectRatio: '1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: isSelected
? 'rgba(34,139,230,0.08)'
: 'var(--mantine-color-gray-1)',
}
: {}),
}}
>
{block.variable ? `{{${block.variable}}}` : block.text || ' '}
{isImage ? (
<IconPhoto size={18} color="var(--mantine-color-gray-6)" />
) : block.variable ? (
`{{${block.variable}}}`
) : (
block.text || ' '
)}
{isSelected && !disabled && (
<span

View File

@@ -1,19 +1,15 @@
import { Button, Code, ScrollArea, Stack, Text, Tooltip } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react';
import { IconPhoto, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
interface Variable {
key: string;
label: string;
}
import type { TemplateVariable } from '@ema-platform/api';
interface Props {
variables: Variable[];
variables: TemplateVariable[];
disabled: boolean;
/** Canvas mode: drops a block onto the page. Source mode: inserts at caret. */
canvasMode: boolean;
onInsert: (key: string) => void;
onAddBlock: (key: string) => void;
onAddBlock: (key: string, kind: 'text' | 'image') => void;
onAddTextBlock: () => void;
}
@@ -60,8 +56,13 @@ export function TemplateVariableList({
variant="default"
justify="flex-start"
disabled={disabled}
leftSection={
variable.kind === 'image' ? <IconPhoto size={12} /> : undefined
}
onClick={() =>
canvasMode ? onAddBlock(variable.key) : onInsert(variable.key)
canvasMode
? onAddBlock(variable.key, variable.kind === 'image' ? 'image' : 'text')
: onInsert(variable.key)
}
>
<Code fz={10}>{`{{${variable.key}}}`}</Code>