Save signatures to server (#5080)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
Reece Browne
2025-11-29 19:29:06 +00:00
committed by GitHub
co-authored by Anthony Stirling
parent fde449e738
commit 651f17f1c6
19 changed files with 1674 additions and 425 deletions
@@ -2267,8 +2267,16 @@ defaultCanvasLabel = "Drawing signature"
defaultImageLabel = "Uploaded signature"
defaultTextLabel = "Typed signature"
saveButton = "Save signature"
savePersonal = "Save Personal"
saveShared = "Save Shared"
saveUnavailable = "Create a signature first to save it."
noChanges = "Current signature is already saved."
tempStorageTitle = "Temporary browser storage"
tempStorageDescription = "Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers."
personalHeading = "Personal Signatures"
sharedHeading = "Shared Signatures"
personalDescription = "Only you can see these signatures."
sharedDescription = "All users can see and use these signatures."
[sign.saved.type]
canvas = "Drawing"
@@ -1,13 +1,15 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ActionIcon, Alert, Badge, Box, Card, Group, Stack, Text, TextInput, Tooltip } from '@mantine/core';
import { LocalIcon } from '@app/components/shared/LocalIcon';
import { MAX_SAVED_SIGNATURES, SavedSignature, SavedSignatureType } from '@app/hooks/tools/sign/useSavedSignatures';
import type { StorageType } from '@app/services/signatureStorageService';
interface SavedSignaturesSectionProps {
signatures: SavedSignature[];
disabled?: boolean;
isAtCapacity: boolean;
storageType?: StorageType | null;
onUseSignature: (signature: SavedSignature) => void;
onDeleteSignature: (signature: SavedSignature) => void;
onRenameSignature: (id: string, label: string) => void;
@@ -24,6 +26,7 @@ export const SavedSignaturesSection = ({
signatures,
disabled = false,
isAtCapacity,
storageType: _storageType,
onUseSignature,
onDeleteSignature,
onRenameSignature,
@@ -36,20 +39,30 @@ export const SavedSignaturesSection = ({
[t, translationScope]
);
const [labelDrafts, setLabelDrafts] = useState<Record<string, string>>({});
const [activeIndex, setActiveIndex] = useState(0);
const activeSignature = signatures[activeIndex];
const activeSignatureRef = useRef<SavedSignature | null>(activeSignature ?? null);
const appliedSignatureIdRef = useRef<string | null>(null);
// Group signatures by scope
const groupedSignatures = useMemo(() => {
const personal = signatures.filter(sig => sig.scope === 'personal');
const shared = signatures.filter(sig => sig.scope === 'shared');
const localStorage = signatures.filter(sig => sig.scope === 'localStorage');
return { personal, shared, localStorage };
}, [signatures]);
// Separate carousel state for each category
const [activePersonalIndex, setActivePersonalIndex] = useState(0);
const [activeSharedIndex, setActiveSharedIndex] = useState(0);
const [activeLocalStorageIndex, setActiveLocalStorageIndex] = useState(0);
const activePersonalSignature = groupedSignatures.personal[activePersonalIndex];
const activeSharedSignature = groupedSignatures.shared[activeSharedIndex];
const activeLocalStorageSignature = groupedSignatures.localStorage[activeLocalStorageIndex];
const onUseSignatureRef = useRef(onUseSignature);
useEffect(() => {
onUseSignatureRef.current = onUseSignature;
}, [onUseSignature]);
useEffect(() => {
activeSignatureRef.current = activeSignature ?? null;
}, [activeSignature]);
useEffect(() => {
setLabelDrafts(prev => {
const nextDrafts: Record<string, string> = {};
@@ -60,25 +73,18 @@ export const SavedSignaturesSection = ({
});
}, [signatures]);
// Reset carousel indices when categories change
useEffect(() => {
if (signatures.length === 0) {
setActiveIndex(0);
return;
}
setActiveIndex(prev => Math.min(prev, Math.max(signatures.length - 1, 0)));
}, [signatures.length]);
setActivePersonalIndex(prev => Math.min(prev, Math.max(groupedSignatures.personal.length - 1, 0)));
}, [groupedSignatures.personal.length]);
const handleNavigate = useCallback(
(direction: 'prev' | 'next') => {
setActiveIndex(prev => {
if (direction === 'prev') {
return Math.max(0, prev - 1);
}
return Math.min(signatures.length - 1, prev + 1);
});
},
[signatures.length]
);
useEffect(() => {
setActiveSharedIndex(prev => Math.min(prev, Math.max(groupedSignatures.shared.length - 1, 0)));
}, [groupedSignatures.shared.length]);
useEffect(() => {
setActiveLocalStorageIndex(prev => Math.min(prev, Math.max(groupedSignatures.localStorage.length - 1, 0)));
}, [groupedSignatures.localStorage.length]);
const renderPreview = (signature: SavedSignature) => {
if (signature.type === 'text') {
@@ -193,21 +199,6 @@ export const SavedSignaturesSection = ({
}
};
useEffect(() => {
const signature = activeSignatureRef.current;
if (!signature || disabled) {
appliedSignatureIdRef.current = null;
return;
}
if (appliedSignatureIdRef.current === signature.id) {
return;
}
appliedSignatureIdRef.current = signature.id;
onUseSignatureRef.current(signature);
}, [activeSignature?.id, disabled]);
return (
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
@@ -234,67 +225,253 @@ export const SavedSignaturesSection = ({
{signatures.length === 0 ? (
emptyState
) : (
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
current: activeIndex + 1,
total: signatures.length,
})}
</Text>
<Group gap={4}>
<ActionIcon
variant="light"
aria-label={translate('saved.prev', 'Previous')}
onClick={() => handleNavigate('prev')}
disabled={disabled || activeIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
aria-label={translate('saved.next', 'Next')}
onClick={() => handleNavigate('next')}
disabled={disabled || activeIndex >= signatures.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
<Stack gap="md">
{/* Personal Signatures */}
{groupedSignatures.personal.length > 0 && activePersonalSignature && (
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon="material-symbols:person-rounded" width={18} height={18} />
<Text fw={600} size="sm">
{translate('saved.personalHeading', 'Personal Signatures')}
</Text>
</Group>
<Text size="xs" c="dimmed">
{translate('saved.personalDescription', 'Only you can see these signatures.')}
</Text>
{activeSignature && (
<Card withBorder padding="sm" key={activeSignature.id}>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge color={typeBadgeColor[activeSignature.type]} variant="light">
{typeLabel(activeSignature.type)}
</Badge>
<Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={translate('saved.delete', 'Remove')}
onClick={() => onDeleteSignature(activeSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
current: activePersonalIndex + 1,
total: groupedSignatures.personal.length,
})}
</Text>
<Group gap={4}>
<ActionIcon
variant="light"
aria-label={translate('saved.prev', 'Previous')}
onClick={() => setActivePersonalIndex(prev => Math.max(0, prev - 1))}
disabled={disabled || activePersonalIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
aria-label={translate('saved.next', 'Next')}
onClick={() => setActivePersonalIndex(prev => Math.min(groupedSignatures.personal.length - 1, prev + 1))}
disabled={disabled || activePersonalIndex >= groupedSignatures.personal.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
{renderPreview(activeSignature)}
<Card withBorder padding="sm">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge color={typeBadgeColor[activePersonalSignature.type]} variant="light">
{typeLabel(activePersonalSignature.type)}
</Badge>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
aria-label="Use signature"
onClick={() => onUseSignature(activePersonalSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
</ActionIcon>
<Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={translate('saved.delete', 'Remove')}
onClick={() => onDeleteSignature(activePersonalSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
{renderPreview(activePersonalSignature)}
<TextInput
label={translate('saved.label', 'Label')}
value={labelDrafts[activePersonalSignature.id] ?? activePersonalSignature.label}
onChange={event => handleLabelChange(event, activePersonalSignature)}
onBlur={() => handleLabelBlur(activePersonalSignature)}
onKeyDown={event => handleLabelKeyDown(event, activePersonalSignature)}
disabled={disabled}
/>
</Stack>
</Card>
</Stack>
)}
<TextInput
label={translate('saved.label', 'Label')}
value={labelDrafts[activeSignature.id] ?? activeSignature.label}
onChange={event => handleLabelChange(event, activeSignature)}
onBlur={() => handleLabelBlur(activeSignature)}
onKeyDown={event => handleLabelKeyDown(event, activeSignature)}
disabled={disabled}
/>
{/* Shared Signatures */}
{groupedSignatures.shared.length > 0 && activeSharedSignature && (
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon="material-symbols:groups-rounded" width={18} height={18} />
<Text fw={600} size="sm">
{translate('saved.sharedHeading', 'Shared Signatures')}
</Text>
</Group>
<Text size="xs" c="dimmed">
{translate('saved.sharedDescription', 'All users can see and use these signatures.')}
</Text>
</Stack>
</Card>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
current: activeSharedIndex + 1,
total: groupedSignatures.shared.length,
})}
</Text>
<Group gap={4}>
<ActionIcon
variant="light"
aria-label={translate('saved.prev', 'Previous')}
onClick={() => setActiveSharedIndex(prev => Math.max(0, prev - 1))}
disabled={disabled || activeSharedIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
aria-label={translate('saved.next', 'Next')}
onClick={() => setActiveSharedIndex(prev => Math.min(groupedSignatures.shared.length - 1, prev + 1))}
disabled={disabled || activeSharedIndex >= groupedSignatures.shared.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
<Card withBorder padding="sm">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge color={typeBadgeColor[activeSharedSignature.type]} variant="light">
{typeLabel(activeSharedSignature.type)}
</Badge>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
aria-label="Use signature"
onClick={() => onUseSignature(activeSharedSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
</ActionIcon>
<Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={translate('saved.delete', 'Remove')}
onClick={() => onDeleteSignature(activeSharedSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
{renderPreview(activeSharedSignature)}
<TextInput
label={translate('saved.label', 'Label')}
value={labelDrafts[activeSharedSignature.id] ?? activeSharedSignature.label}
onChange={event => handleLabelChange(event, activeSharedSignature)}
onBlur={() => handleLabelBlur(activeSharedSignature)}
onKeyDown={event => handleLabelKeyDown(event, activeSharedSignature)}
disabled={disabled}
/>
</Stack>
</Card>
</Stack>
)}
{/* Browser Storage (localStorage) - Temporary */}
{groupedSignatures.localStorage.length > 0 && activeLocalStorageSignature && (
<Stack gap="xs">
<Alert color="blue" title={translate('saved.tempStorageTitle', 'Temporary browser storage')}>
<Text size="xs">
{translate(
'saved.tempStorageDescription',
'Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers.'
)}
</Text>
</Alert>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
current: activeLocalStorageIndex + 1,
total: groupedSignatures.localStorage.length,
})}
</Text>
<Group gap={4}>
<ActionIcon
variant="light"
aria-label={translate('saved.prev', 'Previous')}
onClick={() => setActiveLocalStorageIndex(prev => Math.max(0, prev - 1))}
disabled={disabled || activeLocalStorageIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
aria-label={translate('saved.next', 'Next')}
onClick={() => setActiveLocalStorageIndex(prev => Math.min(groupedSignatures.localStorage.length - 1, prev + 1))}
disabled={disabled || activeLocalStorageIndex >= groupedSignatures.localStorage.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
<Card withBorder padding="sm">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge color={typeBadgeColor[activeLocalStorageSignature.type]} variant="light">
{typeLabel(activeLocalStorageSignature.type)}
</Badge>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
aria-label="Use signature"
onClick={() => onUseSignature(activeLocalStorageSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
</ActionIcon>
<Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={translate('saved.delete', 'Remove')}
onClick={() => onDeleteSignature(activeLocalStorageSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
{renderPreview(activeLocalStorageSignature)}
<TextInput
label={translate('saved.label', 'Label')}
value={labelDrafts[activeLocalStorageSignature.id] ?? activeLocalStorageSignature.label}
onChange={event => handleLabelChange(event, activeLocalStorageSignature)}
onBlur={() => handleLabelBlur(activeLocalStorageSignature)}
onKeyDown={event => handleLabelKeyDown(event, activeLocalStorageSignature)}
disabled={disabled}
/>
</Stack>
</Card>
</Stack>
)}
</Stack>
)}
@@ -16,6 +16,7 @@ import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import { useSavedSignatures, SavedSignature, SavedSignaturePayload, SavedSignatureType, MAX_SAVED_SIGNATURES, AddSignatureResult } from '@app/hooks/tools/sign/useSavedSignatures';
import { SavedSignaturesSection } from '@app/components/tools/sign/SavedSignaturesSection';
import { buildSignaturePreview } from '@app/utils/signaturePreview';
type SignatureDrafts = {
canvas?: string;
@@ -99,6 +100,7 @@ const SignSettings = ({
removeSignature,
updateSignatureLabel,
byTypeCounts,
storageType,
} = useSavedSignatures();
const [signatureSource, setSignatureSource] = useState<SignatureSource>(() => {
const paramSource = parameters.signatureType as SignatureSource;
@@ -157,11 +159,11 @@ const SignSettings = ({
}, [canvasSignatureData, imageSignatureData, buildTextSignatureKey, parameters.signerName, parameters.fontSize, parameters.fontFamily, parameters.textColor]);
const saveSignatureToLibrary = useCallback(
(payload: SavedSignaturePayload, type: SavedSignatureType): AddSignatureResult => {
async (payload: SavedSignaturePayload, type: SavedSignatureType, scope: 'personal' | 'shared'): Promise<AddSignatureResult> => {
if (isSavedSignatureLimitReached) {
return { success: false, reason: 'limit' };
}
return addSignature(payload, getDefaultSavedLabel(type));
return await addSignature(payload, getDefaultSavedLabel(type), scope);
},
[addSignature, getDefaultSavedLabel, isSavedSignatureLimitReached]
);
@@ -176,40 +178,57 @@ const SignSettings = ({
[signatureKeysByType]
);
const handleSaveCanvasSignature = useCallback(() => {
const handleSaveCanvasSignature = useCallback(async (scope: 'personal' | 'shared') => {
if (!canvasSignatureData) {
return;
}
const result = saveSignatureToLibrary({ type: 'canvas', dataUrl: canvasSignatureData }, 'canvas');
const result = await saveSignatureToLibrary({ type: 'canvas', dataUrl: canvasSignatureData }, 'canvas', scope);
if (result.success) {
setLastSavedKeyForType('canvas');
}
}, [canvasSignatureData, saveSignatureToLibrary, setLastSavedKeyForType]);
const handleSaveImageSignature = useCallback(() => {
const handleSaveImageSignature = useCallback(async (scope: 'personal' | 'shared') => {
if (!imageSignatureData) {
return;
}
const result = saveSignatureToLibrary({ type: 'image', dataUrl: imageSignatureData }, 'image');
const result = await saveSignatureToLibrary({ type: 'image', dataUrl: imageSignatureData }, 'image', scope);
if (result.success) {
setLastSavedKeyForType('image');
}
}, [imageSignatureData, saveSignatureToLibrary, setLastSavedKeyForType]);
const handleSaveTextSignature = useCallback(() => {
const handleSaveTextSignature = useCallback(async (scope: 'personal' | 'shared') => {
const signerName = (parameters.signerName ?? '').trim();
if (!signerName) {
return;
}
const result = saveSignatureToLibrary(
// Generate image from text signature
const preview = await buildSignaturePreview({
signatureType: 'text',
signerName,
fontFamily: parameters.fontFamily ?? 'Helvetica',
fontSize: parameters.fontSize ?? 16,
textColor: parameters.textColor ?? '#000000',
});
if (!preview?.dataUrl) {
console.error('Failed to generate text signature preview');
return;
}
const result = await saveSignatureToLibrary(
{
type: 'text',
dataUrl: preview.dataUrl,
signerName,
fontFamily: parameters.fontFamily ?? 'Helvetica',
fontSize: parameters.fontSize ?? 16,
textColor: parameters.textColor ?? '#000000',
},
'text'
'text',
scope
);
if (result.success) {
setLastSavedKeyForType('text');
@@ -286,16 +305,21 @@ const SignSettings = ({
[updateSignatureLabel]
);
const renderSaveButton = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => {
const renderSaveButton = (type: SavedSignatureType, isReady: boolean, onClick: (scope: 'personal' | 'shared') => void, scope: 'personal' | 'shared', icon: string, label: string) => {
if (!canUseSavedLibrary) {
return null;
}
const label = translate('saved.saveButton', 'Save signature');
const currentKey = signatureKeysByType[type];
const lastSavedKey = lastSavedSignatureKeys[type];
const hasChanges = Boolean(currentKey && currentKey !== lastSavedKey);
const isSaved = isReady && !hasChanges;
// Only show backend storage buttons when backend is available
const showButton = storageType === 'backend' || storageType === null;
if (!showButton) {
return null;
}
let tooltipMessage: string | undefined;
if (!isReady) {
tooltipMessage = translate('saved.saveUnavailable', 'Create a signature first to save it.');
@@ -312,11 +336,11 @@ const SignSettings = ({
size="xs"
variant="outline"
color={isSaved ? 'green' : undefined}
onClick={onClick}
onClick={() => onClick(scope)}
disabled={!isReady || disabled || isSavedSignatureLimitReached || !hasChanges}
leftSection={<LocalIcon icon="material-symbols:save-rounded" width={16} height={16} />}
leftSection={<LocalIcon icon={icon} width={16} height={16} />}
>
{isSaved ? translate('saved.status.saved', 'Saved') : label}
{label}
</Button>
);
@@ -331,15 +355,38 @@ const SignSettings = ({
return button;
};
const renderSaveButtonRow = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => {
const button = renderSaveButton(type, isReady, onClick);
if (!button) {
const renderSaveButtonRow = (type: SavedSignatureType, isReady: boolean, onClick: (scope: 'personal' | 'shared') => void) => {
if (!canUseSavedLibrary) {
return null;
}
const personalButton = renderSaveButton(
type,
isReady,
onClick,
'personal',
'material-symbols:person-rounded',
translate('saved.savePersonal', 'Save Personal')
);
const sharedButton = renderSaveButton(
type,
isReady,
onClick,
'shared',
'material-symbols:groups-rounded',
translate('saved.saveShared', 'Save Shared')
);
if (!personalButton && !sharedButton) {
return null;
}
return (
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
{button}
</Box>
<Group gap="xs" style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
{personalButton}
{sharedButton}
</Group>
);
};
@@ -744,6 +791,7 @@ const SignSettings = ({
signatures={savedSignatures}
disabled={disabled}
isAtCapacity={isSavedSignatureLimitReached}
storageType={storageType}
onUseSignature={handleUseSavedSignature}
onDeleteSignature={handleDeleteSavedSignature}
onRenameSignature={handleRenameSavedSignature}
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { signatureStorageService, type StorageType } from '@app/services/signatureStorageService';
const STORAGE_KEY = 'stirling:saved-signatures:v1';
export const MAX_SAVED_SIGNATURES = 10;
export type SavedSignatureType = 'canvas' | 'image' | 'text';
export type SignatureScope = 'personal' | 'shared' | 'localStorage';
export type SavedSignaturePayload =
| {
@@ -16,6 +17,7 @@ export type SavedSignaturePayload =
}
| {
type: 'text';
dataUrl: string;
signerName: string;
fontFamily: string;
fontSize: number;
@@ -25,6 +27,7 @@ export type SavedSignaturePayload =
export type SavedSignature = SavedSignaturePayload & {
id: string;
label: string;
scope: SignatureScope;
createdAt: number;
updatedAt: number;
};
@@ -35,68 +38,6 @@ export type AddSignatureResult =
const isSupportedEnvironment = () => typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
const safeParse = (raw: string | null): SavedSignature[] => {
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((entry: any): entry is SavedSignature => {
if (!entry || typeof entry !== 'object') {
return false;
}
if (typeof entry.id !== 'string' || typeof entry.label !== 'string') {
return false;
}
if (typeof entry.type !== 'string') {
return false;
}
if (entry.type === 'text') {
return (
typeof entry.signerName === 'string' &&
typeof entry.fontFamily === 'string' &&
typeof entry.fontSize === 'number' &&
typeof entry.textColor === 'string'
);
}
return typeof entry.dataUrl === 'string';
});
} catch {
return [];
}
};
const readFromStorage = (): SavedSignature[] => {
if (!isSupportedEnvironment()) {
return [];
}
try {
return safeParse(window.localStorage.getItem(STORAGE_KEY));
} catch {
return [];
}
};
const writeToStorage = (entries: SavedSignature[]) => {
if (!isSupportedEnvironment()) {
return;
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
// Swallow storage errors silently; we still keep state in memory.
}
};
const generateId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
@@ -105,29 +46,61 @@ const generateId = () => {
};
export const useSavedSignatures = () => {
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>(() => readFromStorage());
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>([]);
const [storageType, setStorageType] = useState<StorageType | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Load signatures and detect storage type on mount
useEffect(() => {
if (!isSupportedEnvironment()) {
const loadSignatures = async () => {
try {
const [signatures, type] = await Promise.all([
signatureStorageService.loadSignatures(),
signatureStorageService.getStorageType(),
]);
setSavedSignatures(signatures);
setStorageType(type);
} catch (error) {
console.error('[useSavedSignatures] Failed to load signatures:', error);
} finally {
setIsLoading(false);
}
};
loadSignatures();
}, []);
// Attempt migration from localStorage to backend when backend becomes available
useEffect(() => {
if (storageType === 'backend' && !isLoading) {
signatureStorageService.migrateToBackend().then(result => {
if (result.migrated > 0) {
console.log(`[useSavedSignatures] Migrated ${result.migrated} signatures to backend`);
// Reload after migration
signatureStorageService.loadSignatures().then(setSavedSignatures);
}
});
}
}, [storageType, isLoading]);
// Listen for storage events (for localStorage only)
useEffect(() => {
if (!isSupportedEnvironment() || storageType !== 'localStorage') {
return;
}
const syncFromStorage = () => {
setSavedSignatures(readFromStorage());
signatureStorageService.loadSignatures().then(setSavedSignatures);
};
window.addEventListener('storage', syncFromStorage);
return () => window.removeEventListener('storage', syncFromStorage);
}, []);
useEffect(() => {
writeToStorage(savedSignatures);
}, [savedSignatures]);
}, [storageType]);
const isAtCapacity = savedSignatures.length >= MAX_SAVED_SIGNATURES;
const addSignature = useCallback(
(payload: SavedSignaturePayload, label?: string): AddSignatureResult => {
async (payload: SavedSignaturePayload, label?: string, scope?: SignatureScope): Promise<AddSignatureResult> => {
if (
(payload.type === 'text' && !payload.signerName.trim()) ||
((payload.type === 'canvas' || payload.type === 'image') && !payload.dataUrl)
@@ -135,62 +108,85 @@ export const useSavedSignatures = () => {
return { success: false, reason: 'invalid' };
}
let createdSignature: SavedSignature | null = null;
setSavedSignatures(prev => {
if (prev.length >= MAX_SAVED_SIGNATURES) {
return prev;
}
if (savedSignatures.length >= MAX_SAVED_SIGNATURES) {
return { success: false, reason: 'limit' };
}
const timestamp = Date.now();
const nextEntry: SavedSignature = {
...payload,
id: generateId(),
label: (label || 'Signature').trim() || 'Signature',
createdAt: timestamp,
updatedAt: timestamp,
};
createdSignature = nextEntry;
return [nextEntry, ...prev];
});
const timestamp = Date.now();
const newSignature: SavedSignature = {
...payload,
id: generateId(),
label: (label || 'Signature').trim() || 'Signature',
scope: scope || (storageType === 'backend' ? 'personal' : 'localStorage'),
createdAt: timestamp,
updatedAt: timestamp,
};
return createdSignature
? { success: true, signature: createdSignature }
: { success: false, reason: 'limit' };
try {
await signatureStorageService.saveSignature(newSignature);
setSavedSignatures(prev => [newSignature, ...prev]);
return { success: true, signature: newSignature };
} catch (error) {
console.error('[useSavedSignatures] Failed to save signature:', error);
return { success: false, reason: 'invalid' };
}
},
[]
[savedSignatures.length, storageType]
);
const removeSignature = useCallback((id: string) => {
setSavedSignatures(prev => prev.filter(entry => entry.id !== id));
const removeSignature = useCallback(async (id: string) => {
try {
await signatureStorageService.deleteSignature(id);
setSavedSignatures(prev => prev.filter(entry => entry.id !== id));
} catch (error) {
console.error('[useSavedSignatures] Failed to delete signature:', error);
}
}, []);
const updateSignatureLabel = useCallback((id: string, nextLabel: string) => {
setSavedSignatures(prev =>
prev.map(entry =>
entry.id === id
? { ...entry, label: nextLabel.trim() || entry.label || 'Signature', updatedAt: Date.now() }
: entry
)
);
const updateSignatureLabel = useCallback(async (id: string, nextLabel: string) => {
try {
await signatureStorageService.updateSignatureLabel(id, nextLabel);
setSavedSignatures(prev =>
prev.map(entry =>
entry.id === id
? { ...entry, label: nextLabel.trim() || entry.label || 'Signature', updatedAt: Date.now() }
: entry
)
);
} catch (error) {
console.error('[useSavedSignatures] Failed to update signature label:', error);
}
}, []);
const replaceSignature = useCallback((id: string, payload: SavedSignaturePayload) => {
setSavedSignatures(prev =>
prev.map(entry =>
entry.id === id
? {
...entry,
...payload,
updatedAt: Date.now(),
}
: entry
)
);
}, []);
const replaceSignature = useCallback(
async (id: string, payload: SavedSignaturePayload) => {
const existing = savedSignatures.find(s => s.id === id);
if (!existing) return;
const clearSignatures = useCallback(() => {
setSavedSignatures([]);
}, []);
const updated: SavedSignature = {
...existing,
...payload,
updatedAt: Date.now(),
};
try {
await signatureStorageService.saveSignature(updated);
setSavedSignatures(prev => prev.map(entry => (entry.id === id ? updated : entry)));
} catch (error) {
console.error('[useSavedSignatures] Failed to replace signature:', error);
}
},
[savedSignatures]
);
const clearSignatures = useCallback(async () => {
try {
await Promise.all(savedSignatures.map(sig => signatureStorageService.deleteSignature(sig.id)));
setSavedSignatures([]);
} catch (error) {
console.error('[useSavedSignatures] Failed to clear signatures:', error);
}
}, [savedSignatures]);
const byTypeCounts = useMemo(() => {
return savedSignatures.reduce<Record<SavedSignatureType, number>>(
@@ -211,6 +207,8 @@ export const useSavedSignatures = () => {
replaceSignature,
clearSignatures,
byTypeCounts,
storageType,
isLoading,
};
};
@@ -0,0 +1,282 @@
import apiClient from '@app/services/apiClient';
import type { SavedSignature } from '@app/hooks/tools/sign/useSavedSignatures';
export type StorageType = 'backend' | 'localStorage';
interface SignatureStorageCapabilities {
supportsBackend: boolean;
storageType: StorageType;
}
/**
* Service to handle signature storage with adaptive backend/localStorage fallback
*/
class SignatureStorageService {
private capabilities: SignatureStorageCapabilities | null = null;
private detectionPromise: Promise<SignatureStorageCapabilities> | null = null;
private blobUrls: Set<string> = new Set();
/**
* Detect if backend supports signature storage API
*/
async detectCapabilities(): Promise<SignatureStorageCapabilities> {
// Return cached result if already detected
if (this.capabilities) {
return this.capabilities;
}
// Return in-flight detection if already running
if (this.detectionPromise) {
return this.detectionPromise;
}
// Start new detection
this.detectionPromise = this._performDetection();
this.capabilities = await this.detectionPromise;
this.detectionPromise = null;
return this.capabilities;
}
private async _performDetection(): Promise<SignatureStorageCapabilities> {
try {
// Probe the proprietary signatures endpoint (requires authentication)
await apiClient.get('/api/v1/proprietary/signatures', {
timeout: 3000,
});
// 200 = Backend available and accessible (authenticated)
console.log('[SignatureStorage] Backend signature API detected and accessible (authenticated)');
return {
supportsBackend: true,
storageType: 'backend',
};
} catch (error: any) {
// Check if it's an HTTP error with status code
if (error?.response?.status === 401 || error?.response?.status === 403) {
// Backend exists but needs auth - gracefully fall back to localStorage
console.log('[SignatureStorage] Backend signature API requires authentication, using localStorage');
} else if (error?.response?.status === 404) {
// Endpoint doesn't exist (not running proprietary mode)
console.log('[SignatureStorage] Backend signature API not available (not in proprietary mode), using localStorage');
} else {
// Network error, timeout, or other error
console.log('[SignatureStorage] Backend signature API not available, using localStorage');
}
return {
supportsBackend: false,
storageType: 'localStorage',
};
}
}
/**
* Get current storage type
*/
async getStorageType(): Promise<StorageType> {
const capabilities = await this.detectCapabilities();
return capabilities.storageType;
}
/**
* Load all signatures
*/
async loadSignatures(): Promise<SavedSignature[]> {
// Clean up old blob URLs before loading new ones
this.cleanup();
const capabilities = await this.detectCapabilities();
if (capabilities.supportsBackend) {
return this._loadFromBackend();
} else {
return this._loadFromLocalStorage();
}
}
/**
* Save a signature
*/
async saveSignature(signature: SavedSignature): Promise<void> {
const capabilities = await this.detectCapabilities();
if (capabilities.supportsBackend && signature.scope !== 'localStorage') {
await this._saveToBackend(signature);
} else {
// Force scope to localStorage for browser storage
signature.scope = 'localStorage';
this._saveToLocalStorage(signature);
}
}
/**
* Delete a signature
*/
async deleteSignature(id: string): Promise<void> {
const capabilities = await this.detectCapabilities();
if (capabilities.supportsBackend) {
await this._deleteFromBackend(id);
} else {
this._deleteFromLocalStorage(id);
}
}
/**
* Update signature label
*/
async updateSignatureLabel(id: string, label: string): Promise<void> {
const capabilities = await this.detectCapabilities();
if (capabilities.supportsBackend) {
// Backend only stores images - labels not supported for backend signatures
console.log('[SignatureStorage] Label updates not supported for backend signatures');
return;
} else {
this._updateLabelInLocalStorage(id, label);
}
}
// Backend methods
private async _loadFromBackend(): Promise<SavedSignature[]> {
try {
const response = await apiClient.get<SavedSignature[]>('/api/v1/proprietary/signatures');
const signatures = response.data;
// Fetch image data for each signature and convert to blob URLs
const signaturePromises = signatures.map(async (sig) => {
if (sig.dataUrl && sig.dataUrl.startsWith('/api/v1/general/signatures/')) {
try {
// Fetch image via apiClient (unified endpoint works for both authenticated and unauthenticated)
const imageResponse = await apiClient.get<ArrayBuffer>(sig.dataUrl, {
responseType: 'arraybuffer',
});
// Convert to blob URL
const blob = new Blob([imageResponse.data], {
type: imageResponse.headers['content-type'] || 'image/png',
});
const blobUrl = URL.createObjectURL(blob);
this.blobUrls.add(blobUrl);
return { ...sig, dataUrl: blobUrl };
} catch (error) {
console.error(`[SignatureStorage] Failed to load image for ${sig.id}:`, error);
return sig; // Return original if image fetch fails
}
}
return sig;
});
return await Promise.all(signaturePromises);
} catch (error) {
console.error('[SignatureStorage] Failed to load from backend:', error);
return [];
}
}
private async _saveToBackend(signature: SavedSignature): Promise<void> {
await apiClient.post('/api/v1/proprietary/signatures', signature);
}
private async _deleteFromBackend(id: string): Promise<void> {
await apiClient.delete(`/api/v1/proprietary/signatures/${id}`);
}
// LocalStorage methods
private readonly STORAGE_KEY = 'stirling:saved-signatures:v1';
private _loadFromLocalStorage(): SavedSignature[] {
try {
const raw = localStorage.getItem(this.STORAGE_KEY);
if (!raw) return [];
const signatures = JSON.parse(raw);
// Ensure all localStorage signatures have the correct scope
return signatures.map((sig: SavedSignature) => ({
...sig,
scope: 'localStorage' as const,
}));
} catch {
return [];
}
}
private _saveToLocalStorage(signature: SavedSignature): void {
const signatures = this._loadFromLocalStorage();
const index = signatures.findIndex(s => s.id === signature.id);
if (index >= 0) {
signatures[index] = signature;
} else {
signatures.unshift(signature);
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures));
}
private _deleteFromLocalStorage(id: string): void {
const signatures = this._loadFromLocalStorage();
const filtered = signatures.filter(s => s.id !== id);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered));
}
private _updateLabelInLocalStorage(id: string, label: string): void {
const signatures = this._loadFromLocalStorage();
const signature = signatures.find(s => s.id === id);
if (signature) {
signature.label = label;
signature.updatedAt = Date.now();
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures));
}
}
/**
* Migrate signatures from localStorage to backend
*/
async migrateToBackend(): Promise<{ migrated: number; failed: number }> {
const capabilities = await this.detectCapabilities();
if (!capabilities.supportsBackend) {
return { migrated: 0, failed: 0 };
}
const localSignatures = this._loadFromLocalStorage();
if (localSignatures.length === 0) {
return { migrated: 0, failed: 0 };
}
let migrated = 0;
let failed = 0;
for (const signature of localSignatures) {
try {
await this._saveToBackend(signature);
migrated++;
} catch (error) {
console.error(`[SignatureStorage] Failed to migrate signature ${signature.id}:`, error);
failed++;
}
}
// Clear localStorage after successful migration
if (migrated > 0 && failed === 0) {
localStorage.removeItem(this.STORAGE_KEY);
console.log(`[SignatureStorage] Successfully migrated ${migrated} signatures to backend`);
}
return { migrated, failed };
}
/**
* Clean up blob URLs to prevent memory leaks
*/
cleanup(): void {
this.blobUrls.forEach(url => {
URL.revokeObjectURL(url);
});
this.blobUrls.clear();
}
}
export const signatureStorageService = new SignatureStorageService();