mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 15:25:47 +00:00
Enhance certificate designer and seafarer registration features with image support and dynamic department/rank options
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -41,14 +41,43 @@ function logoHtml(logoUrl: string, placement: TemplateLogoPlacement): string {
|
||||
return ` <img class="ema-logo" src="${escapeHtml(logoUrl)}" alt="" style="position:absolute;${position}width:${width}%;" />\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable keys the renderer fills with a data URI — the fallback for a
|
||||
* block placed before `type` existed on it. Keep in sync with
|
||||
* `IMAGE_VARIABLE_KEYS` in the server's template-variables.ts; a new image
|
||||
* variable added there should be added here too.
|
||||
*/
|
||||
const IMAGE_VARIABLE_KEYS = new Set([
|
||||
'logo',
|
||||
'holderPhoto',
|
||||
'qrImage',
|
||||
'sealImage',
|
||||
'signatureImage',
|
||||
'seafarerSignature',
|
||||
]);
|
||||
|
||||
function isImageBlock(block: TemplateFieldPlacement): boolean {
|
||||
if (block.type) return block.type === 'image';
|
||||
return !!block.variable && IMAGE_VARIABLE_KEYS.has(block.variable);
|
||||
}
|
||||
|
||||
function blockHtml(block: TemplateFieldPlacement): string {
|
||||
const x = pct(block.xPct, 0);
|
||||
const y = pct(block.yPct, 0);
|
||||
// Minimum 1%, matching the server compiler: a zero-width block would render
|
||||
// as an invisible sliver rather than as the mistake it is.
|
||||
const width = pct(block.widthPct, 30, 1);
|
||||
|
||||
if (isImageBlock(block) && block.variable) {
|
||||
// Triple-brace: the value is a data URI, not markup — escaping it turns
|
||||
// every "&" into "&" and corrupts the src.
|
||||
const style = `position:absolute;left:${x}%;top:${y}%;width:${width}%;object-fit:contain;`;
|
||||
return ` <img class="ema-block" style="${style}" src="{{{${block.variable}}}}" alt="" />\n`;
|
||||
}
|
||||
|
||||
const size = block.fontSize ?? 14;
|
||||
const weight = block.fontWeight === 'bold' ? 'bold' : 'normal';
|
||||
const style_ = block.fontStyle === 'italic' ? 'italic' : 'normal';
|
||||
const align = block.align ?? 'left';
|
||||
const color = escapeHtml(block.color ?? '#111111');
|
||||
|
||||
@@ -58,7 +87,7 @@ function blockHtml(block: TemplateFieldPlacement): string {
|
||||
|
||||
const style =
|
||||
`position:absolute;left:${x}%;top:${y}%;width:${width}%;` +
|
||||
`font-size:${size}px;font-weight:${weight};text-align:${align};color:${color};`;
|
||||
`font-size:${size}px;font-weight:${weight};font-style:${style_};text-align:${align};color:${color};`;
|
||||
|
||||
return ` <div class="ema-block" style="${style}">${content}</div>\n`;
|
||||
}
|
||||
|
||||
@@ -81,23 +81,46 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
const selectedBlock =
|
||||
placements.find((block) => block.id === selectedBlockId) ?? null;
|
||||
|
||||
/** Drops a new block near the top-left, where it is immediately visible. */
|
||||
const addBlock = useCallback((variable: string | null, text?: string) => {
|
||||
const block: TemplateFieldPlacement = {
|
||||
id: blockId(),
|
||||
variable,
|
||||
text,
|
||||
xPct: 10,
|
||||
yPct: 10,
|
||||
widthPct: 30,
|
||||
fontSize: 14,
|
||||
fontWeight: 'normal',
|
||||
align: 'left',
|
||||
color: '#111111',
|
||||
};
|
||||
setPlacements((prev) => [...prev, block]);
|
||||
setSelectedBlockId(block.id);
|
||||
}, []);
|
||||
/**
|
||||
* Drops a new block near the top-left, where it is immediately visible.
|
||||
*
|
||||
* An image block gets a square-ish default footprint instead of the text
|
||||
* defaults (fontSize/color/align mean nothing on an `<img>`) — a seal or
|
||||
* signature dropped at 30% width and no explicit height would otherwise
|
||||
* stretch to whatever the image's own aspect ratio makes of that width,
|
||||
* which reads as broken until the author manually resizes it.
|
||||
*/
|
||||
const addBlock = useCallback(
|
||||
(variable: string | null, text?: string, kind: 'text' | 'image' = 'text') => {
|
||||
const block: TemplateFieldPlacement =
|
||||
kind === 'image'
|
||||
? {
|
||||
id: blockId(),
|
||||
variable,
|
||||
type: 'image',
|
||||
xPct: 10,
|
||||
yPct: 10,
|
||||
widthPct: 15,
|
||||
}
|
||||
: {
|
||||
id: blockId(),
|
||||
variable,
|
||||
text,
|
||||
type: 'text',
|
||||
xPct: 10,
|
||||
yPct: 10,
|
||||
widthPct: 30,
|
||||
fontSize: 14,
|
||||
fontWeight: 'normal',
|
||||
fontStyle: 'normal',
|
||||
align: 'left',
|
||||
color: '#111111',
|
||||
};
|
||||
setPlacements((prev) => [...prev, block]);
|
||||
setSelectedBlockId(block.id);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateBlock = useCallback((next: TemplateFieldPlacement) => {
|
||||
setPlacements((prev) =>
|
||||
|
||||
@@ -392,7 +392,7 @@ export function CertificateDesignerPage() {
|
||||
disabled={editingLocked}
|
||||
canvasMode={mode === 'canvas'}
|
||||
onInsert={draft.insertVariable}
|
||||
onAddBlock={(key) => draft.addBlock(key)}
|
||||
onAddBlock={(key, kind) => draft.addBlock(key, undefined, kind)}
|
||||
onAddTextBlock={() => draft.addBlock(null, 'Text')}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
Reference in New Issue
Block a user