mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat: unify utilities into shared packages (Tier 1)
- Add LanguageSelector component to @manacore/shared-i18n - Add keyboard.ts to @manacore/shared-utils (shortcuts handling) - Add cache.ts to @manacore/shared-utils (IndexedDB caching) - Extend format.ts and date.ts with additional utilities - Update Memoro to use shared utilities with app-specific wrappers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7d426d57fd
commit
74ccad38d5
15 changed files with 877 additions and 647 deletions
205
packages/shared-utils/src/cache.ts
Normal file
205
packages/shared-utils/src/cache.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
/**
|
||||
* IndexedDB Cache Utility
|
||||
* Provides persistent storage for cached data like signed URLs
|
||||
* Survives page reloads and provides better performance than localStorage
|
||||
*/
|
||||
|
||||
interface CacheEntry<T = string> {
|
||||
id: string;
|
||||
data: T;
|
||||
expires: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface CacheConfig {
|
||||
/** Database name */
|
||||
dbName: string;
|
||||
/** Store name */
|
||||
storeName: string;
|
||||
/** Database version */
|
||||
version?: number;
|
||||
}
|
||||
|
||||
let dbPromises: Map<string, Promise<IDBDatabase>> = new Map();
|
||||
|
||||
/**
|
||||
* Initialize IndexedDB connection
|
||||
*/
|
||||
function getDB(config: CacheConfig): Promise<IDBDatabase> {
|
||||
const key = `${config.dbName}:${config.storeName}`;
|
||||
|
||||
if (dbPromises.has(key)) {
|
||||
return dbPromises.get(key)!;
|
||||
}
|
||||
|
||||
const promise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
reject(new Error('IndexedDB not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(config.dbName, config.version || 1);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
// Create store if it doesn't exist
|
||||
if (!db.objectStoreNames.contains(config.storeName)) {
|
||||
const store = db.createObjectStore(config.storeName, { keyPath: 'id' });
|
||||
store.createIndex('expires', 'expires', { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
dbPromises.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a cache instance for a specific database/store
|
||||
*/
|
||||
export function createCache<T = string>(config: CacheConfig) {
|
||||
return {
|
||||
/**
|
||||
* Get cached data by ID
|
||||
* Returns null if not found or expired
|
||||
*/
|
||||
async get(id: string): Promise<T | null> {
|
||||
try {
|
||||
const db = await getDB(config);
|
||||
const transaction = db.transaction(config.storeName, 'readonly');
|
||||
const store = transaction.objectStore(config.storeName);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const request = store.get(id);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const result = request.result as CacheEntry<T> | undefined;
|
||||
if (result && Date.now() < result.expires) {
|
||||
resolve(result.data);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('Error getting cached data:', request.error);
|
||||
resolve(null);
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Store data in cache
|
||||
* @param id - Unique identifier for the cached data
|
||||
* @param data - Data to cache
|
||||
* @param expiresInMs - Time until expiration in milliseconds
|
||||
*/
|
||||
async set(id: string, data: T, expiresInMs: number): Promise<void> {
|
||||
try {
|
||||
const db = await getDB(config);
|
||||
const transaction = db.transaction(config.storeName, 'readwrite');
|
||||
const store = transaction.objectStore(config.storeName);
|
||||
|
||||
const entry: CacheEntry<T> = {
|
||||
id,
|
||||
data,
|
||||
expires: Date.now() + expiresInMs,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
|
||||
store.put(entry);
|
||||
} catch (error) {
|
||||
console.error('Error caching data:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete cached data by ID
|
||||
*/
|
||||
async delete(id: string): Promise<void> {
|
||||
try {
|
||||
const db = await getDB(config);
|
||||
const transaction = db.transaction(config.storeName, 'readwrite');
|
||||
const store = transaction.objectStore(config.storeName);
|
||||
store.delete(id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting cached data:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clean up expired entries
|
||||
* Should be called periodically
|
||||
*/
|
||||
async cleanupExpired(): Promise<void> {
|
||||
try {
|
||||
const db = await getDB(config);
|
||||
const transaction = db.transaction(config.storeName, 'readwrite');
|
||||
const store = transaction.objectStore(config.storeName);
|
||||
const index = store.index('expires');
|
||||
const now = Date.now();
|
||||
|
||||
const request = index.openCursor(IDBKeyRange.upperBound(now));
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor) {
|
||||
cursor.delete();
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up expired entries:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all cached data
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
try {
|
||||
const db = await getDB(config);
|
||||
const transaction = db.transaction(config.storeName, 'readwrite');
|
||||
const store = transaction.objectStore(config.storeName);
|
||||
store.clear();
|
||||
} catch (error) {
|
||||
console.error('Error clearing cache:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if an item exists and is not expired
|
||||
*/
|
||||
async has(id: string): Promise<boolean> {
|
||||
const data = await this.get(id);
|
||||
return data !== null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Default URL cache for signed URLs (like Supabase storage URLs)
|
||||
const DEFAULT_URL_CACHE_CONFIG: CacheConfig = {
|
||||
dbName: 'manacore-cache',
|
||||
storeName: 'urls',
|
||||
version: 1
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-configured cache for signed URLs
|
||||
* Useful for caching Supabase storage signed URLs
|
||||
*/
|
||||
export const urlCache = createCache<string>(DEFAULT_URL_CACHE_CONFIG);
|
||||
|
||||
// Convenience exports for URL caching (backward compatibility)
|
||||
export const getCachedUrl = urlCache.get.bind(urlCache);
|
||||
export const setCachedUrl = urlCache.set.bind(urlCache);
|
||||
export const deleteCachedUrl = urlCache.delete.bind(urlCache);
|
||||
export const cleanupExpiredUrls = urlCache.cleanupExpired.bind(urlCache);
|
||||
export const clearAllCachedUrls = urlCache.clear.bind(urlCache);
|
||||
|
|
@ -10,7 +10,7 @@ const locales = {
|
|||
en: enUS,
|
||||
};
|
||||
|
||||
type LocaleKey = keyof typeof locales;
|
||||
export type LocaleKey = keyof typeof locales;
|
||||
|
||||
/**
|
||||
* Format a date string to a readable format
|
||||
|
|
@ -78,3 +78,16 @@ export function formatTimestamp(
|
|||
* Check if a date is today
|
||||
*/
|
||||
export { isToday, isYesterday } from 'date-fns';
|
||||
|
||||
/**
|
||||
* Format timestamp as short date
|
||||
*
|
||||
* @param date - Date to format
|
||||
* @param locale - Locale for formatting ('de' or 'en')
|
||||
* @returns Formatted short date string (e.g., "15 Mar 2024" or "15. Mär. 2024")
|
||||
*/
|
||||
export function formatShortDate(date: Date | string, locale: LocaleKey = 'de'): string {
|
||||
const dateObj = typeof date === 'string' ? parseISO(date) : date;
|
||||
|
||||
return format(dateObj, 'dd MMM yyyy', { locale: locales[locale] });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,3 +150,29 @@ export function formatPercent(
|
|||
maximumFractionDigits: decimals,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration as compact string (alias for formatDuration)
|
||||
* Kept for compatibility - returns MM:SS or HH:MM:SS
|
||||
*/
|
||||
export const formatDurationCompact = formatDuration;
|
||||
|
||||
/**
|
||||
* Parse duration string to seconds
|
||||
*
|
||||
* @param duration - Duration string (e.g., "1:30" or "1:01:05")
|
||||
* @returns Duration in seconds
|
||||
*/
|
||||
export function parseDuration(duration: string): number {
|
||||
const parts = duration.split(':').map(Number);
|
||||
|
||||
if (parts.length === 3) {
|
||||
// Hours:Minutes:Seconds
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
} else if (parts.length === 2) {
|
||||
// Minutes:Seconds
|
||||
return parts[0] * 60 + parts[1];
|
||||
}
|
||||
|
||||
return parts[0] || 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,3 +16,9 @@ export * from './format';
|
|||
|
||||
// Validation utilities
|
||||
export * from './validation';
|
||||
|
||||
// Keyboard shortcuts
|
||||
export * from './keyboard';
|
||||
|
||||
// IndexedDB Cache
|
||||
export * from './cache';
|
||||
|
|
|
|||
244
packages/shared-utils/src/keyboard.ts
Normal file
244
packages/shared-utils/src/keyboard.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
/**
|
||||
* Keyboard Shortcuts Utility
|
||||
* Provides centralized keyboard shortcut handling for applications
|
||||
*/
|
||||
|
||||
export interface ShortcutAction {
|
||||
key: string;
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
meta?: boolean; // Command key on Mac
|
||||
description: string;
|
||||
action: () => void;
|
||||
preventDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface ShortcutGroup {
|
||||
name: string;
|
||||
shortcuts: ShortcutAction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a keyboard event matches a shortcut
|
||||
*/
|
||||
export function matchesShortcut(event: KeyboardEvent, shortcut: ShortcutAction): boolean {
|
||||
const keyMatches = event.key.toLowerCase() === shortcut.key.toLowerCase();
|
||||
const ctrlMatches = shortcut.ctrl ? event.ctrlKey || event.metaKey : !event.ctrlKey && !event.metaKey;
|
||||
const shiftMatches = shortcut.shift ? event.shiftKey : !event.shiftKey;
|
||||
const altMatches = shortcut.alt ? event.altKey : !event.altKey;
|
||||
|
||||
return keyMatches && ctrlMatches && shiftMatches && altMatches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format shortcut for display (e.g., "Ctrl+S")
|
||||
*/
|
||||
export function formatShortcut(shortcut: ShortcutAction): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (shortcut.ctrl) parts.push('Ctrl');
|
||||
if (shortcut.shift) parts.push('Shift');
|
||||
if (shortcut.alt) parts.push('Alt');
|
||||
parts.push(shortcut.key.toUpperCase());
|
||||
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format shortcut for Mac display (e.g., "⌘S")
|
||||
*/
|
||||
export function formatShortcutMac(shortcut: ShortcutAction): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (shortcut.ctrl) parts.push('⌘');
|
||||
if (shortcut.shift) parts.push('⇧');
|
||||
if (shortcut.alt) parts.push('⌥');
|
||||
parts.push(shortcut.key.toUpperCase());
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create keyboard shortcut handler
|
||||
* @param shortcuts - Array of shortcuts to handle
|
||||
* @param options - Options for the handler
|
||||
*/
|
||||
export function createShortcutHandler(
|
||||
shortcuts: ShortcutAction[],
|
||||
options?: {
|
||||
/** Allow shortcuts in input fields */
|
||||
allowInInputs?: boolean;
|
||||
}
|
||||
) {
|
||||
return (event: KeyboardEvent) => {
|
||||
// Don't handle shortcuts if user is typing in an input (unless explicitly allowed)
|
||||
if (!options?.allowInInputs) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const shortcut of shortcuts) {
|
||||
if (matchesShortcut(event, shortcut)) {
|
||||
if (shortcut.preventDefault !== false) {
|
||||
event.preventDefault();
|
||||
}
|
||||
shortcut.action();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create common shortcuts builder
|
||||
* Helps create consistent shortcut definitions
|
||||
*/
|
||||
export function createShortcuts(actions: {
|
||||
onSearch?: () => void;
|
||||
onSave?: () => void;
|
||||
onEdit?: () => void;
|
||||
onCancel?: () => void;
|
||||
onDelete?: () => void;
|
||||
onNew?: () => void;
|
||||
onCopy?: () => void;
|
||||
onPaste?: () => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
}): ShortcutGroup[] {
|
||||
const shortcuts: ShortcutGroup[] = [];
|
||||
const generalShortcuts: ShortcutAction[] = [];
|
||||
|
||||
if (actions.onSearch) {
|
||||
generalShortcuts.push({
|
||||
key: 'f',
|
||||
ctrl: true,
|
||||
description: 'Search',
|
||||
action: actions.onSearch
|
||||
});
|
||||
}
|
||||
|
||||
if (actions.onSave) {
|
||||
generalShortcuts.push({
|
||||
key: 's',
|
||||
ctrl: true,
|
||||
description: 'Save',
|
||||
action: actions.onSave
|
||||
});
|
||||
}
|
||||
|
||||
if (actions.onEdit) {
|
||||
generalShortcuts.push({
|
||||
key: 'e',
|
||||
ctrl: true,
|
||||
description: 'Edit',
|
||||
action: actions.onEdit
|
||||
});
|
||||
}
|
||||
|
||||
if (actions.onCancel) {
|
||||
generalShortcuts.push({
|
||||
key: 'Escape',
|
||||
description: 'Cancel',
|
||||
action: actions.onCancel
|
||||
});
|
||||
}
|
||||
|
||||
if (actions.onNew) {
|
||||
generalShortcuts.push({
|
||||
key: 'n',
|
||||
ctrl: true,
|
||||
description: 'New',
|
||||
action: actions.onNew
|
||||
});
|
||||
}
|
||||
|
||||
if (actions.onDelete) {
|
||||
generalShortcuts.push({
|
||||
key: 'Delete',
|
||||
ctrl: true,
|
||||
description: 'Delete',
|
||||
action: actions.onDelete
|
||||
});
|
||||
}
|
||||
|
||||
if (actions.onCopy) {
|
||||
generalShortcuts.push({
|
||||
key: 'c',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
description: 'Copy',
|
||||
action: actions.onCopy
|
||||
});
|
||||
}
|
||||
|
||||
if (actions.onUndo) {
|
||||
generalShortcuts.push({
|
||||
key: 'z',
|
||||
ctrl: true,
|
||||
description: 'Undo',
|
||||
action: actions.onUndo
|
||||
});
|
||||
}
|
||||
|
||||
if (actions.onRedo) {
|
||||
generalShortcuts.push({
|
||||
key: 'z',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
description: 'Redo',
|
||||
action: actions.onRedo
|
||||
});
|
||||
}
|
||||
|
||||
if (generalShortcuts.length > 0) {
|
||||
shortcuts.push({
|
||||
name: 'General',
|
||||
shortcuts: generalShortcuts
|
||||
});
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Svelte action for keyboard shortcuts
|
||||
* Usage: <div use:shortcuts={shortcutActions}>
|
||||
*/
|
||||
export function shortcuts(node: HTMLElement, shortcutActions: ShortcutAction[]) {
|
||||
const handler = createShortcutHandler(shortcutActions);
|
||||
|
||||
node.addEventListener('keydown', handler);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('keydown', handler);
|
||||
},
|
||||
update(newShortcutActions: ShortcutAction[]) {
|
||||
node.removeEventListener('keydown', handler);
|
||||
const newHandler = createShortcutHandler(newShortcutActions);
|
||||
node.addEventListener('keydown', newHandler);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running on Mac
|
||||
*/
|
||||
export function isMac(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform-aware shortcut display
|
||||
*/
|
||||
export function getPlatformShortcut(shortcut: ShortcutAction): string {
|
||||
return isMac() ? formatShortcutMac(shortcut) : formatShortcut(shortcut);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue