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
@@ -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,
};
};