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:
Till-JS 2025-11-24 22:19:04 +01:00
parent 7d426d57fd
commit 74ccad38d5
15 changed files with 877 additions and 647 deletions

View 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);

View file

@ -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] });
}

View file

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

View file

@ -16,3 +16,9 @@ export * from './format';
// Validation utilities
export * from './validation';
// Keyboard shortcuts
export * from './keyboard';
// IndexedDB Cache
export * from './cache';

View 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);
}