mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
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:
co-authored by
Anthony Stirling
parent
fde449e738
commit
651f17f1c6
@@ -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();
|
||||
Reference in New Issue
Block a user