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

@ -1,234 +1,35 @@
<script lang="ts">
import { locale } from 'svelte-i18n';
import { setLocale, supportedLocales, type SupportedLocale } from '$lib/i18n';
import { theme } from '$lib/stores/theme';
import { LanguageSelector } from '@manacore/shared-i18n';
import { setLocale, supportedLocales } from '$lib/i18n';
import { theme, type ThemeVariant } from '$lib/stores/theme';
// Language info with flags and names
const languages: Record<SupportedLocale, { flag: string; name: string; code: string }> = {
de: { flag: '🇩🇪', name: 'Deutsch', code: 'DE' },
en: { flag: '🇬🇧', name: 'English', code: 'EN' },
fr: { flag: '🇫🇷', name: 'Français', code: 'FR' },
it: { flag: '🇮🇹', name: 'Italiano', code: 'IT' },
es: { flag: '🇪🇸', name: 'Español', code: 'ES' }
};
let currentTheme = $derived($theme);
let currentTheme = $derived(theme);
let isDark = $derived(currentTheme.effectiveMode === 'dark');
let currentLocale = $derived(($locale as SupportedLocale) || 'en');
let isOpen = $state(false);
let currentLocale = $derived($locale || 'en');
function getPrimaryColor() {
const variantColors: Record<ThemeVariant, { light: string; dark: string }> = {
lume: { light: '#f8d62b', dark: '#f8d62b' },
nature: { light: '#4CAF50', dark: '#4CAF50' },
stone: { light: '#607D8B', dark: '#78909C' },
ocean: { light: '#039BE5', dark: '#039BE5' }
};
function getPrimaryColor(): string {
const variant = currentTheme.variant;
if (isDark) {
const colors = {
lume: '#f8d62b',
nature: '#4CAF50',
stone: '#78909C',
ocean: '#039BE5'
};
return colors[variant];
} else {
const colors = {
lume: '#f8d62b',
nature: '#4CAF50',
stone: '#607D8B',
ocean: '#039BE5'
};
return colors[variant];
}
const colors = variantColors[variant];
return isDark ? colors.dark : colors.light;
}
function handleLocaleChange(newLocale: SupportedLocale) {
setLocale(newLocale);
isOpen = false;
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
function toggleDropdown() {
isOpen = !isOpen;
}
// Close dropdown when clicking outside
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.language-selector')) {
isOpen = false;
}
}
$effect(() => {
if (isOpen) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
</script>
<div class="language-selector">
<button
onclick={toggleDropdown}
class="language-button"
class:active={isOpen}
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.6)'};
border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'};
color: {isDark ? '#ffffff' : '#000000'};"
>
<span class="flag">{languages[currentLocale].flag}</span>
<span class="code">{languages[currentLocale].code}</span>
<svg
class="chevron"
class:rotate={isOpen}
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
>
<path d="M2 4l4 4 4-4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
{#if isOpen}
<div
class="dropdown"
style="background-color: {isDark ? '#1E1E1E' : '#ffffff'};
border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'};
box-shadow: 0 4px 12px rgba(0, 0, 0, {isDark ? '0.4' : '0.15'});"
>
{#each supportedLocales as lang}
<button
onclick={() => handleLocaleChange(lang)}
class="dropdown-item"
class:active={currentLocale === lang}
style="color: {isDark ? '#ffffff' : '#000000'};"
>
<span class="flag">{languages[lang].flag}</span>
<span class="name">{languages[lang].name}</span>
{#if currentLocale === lang}
<svg
class="check"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke={getPrimaryColor()}
>
<path
d="M3 8l3 3 7-7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<style>
.language-selector {
position: relative;
display: inline-block;
}
.language-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
border: 1px solid;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(10px);
}
.language-button:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.language-button.active {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.flag {
font-size: 1.125rem;
line-height: 1;
}
.code {
font-weight: 600;
letter-spacing: 0.025em;
}
.chevron {
transition: transform 0.2s;
margin-left: 0.25rem;
}
.chevron.rotate {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
min-width: 160px;
border-radius: 0.75rem;
border: 1px solid;
overflow: hidden;
z-index: 100;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dropdown-item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: none;
cursor: pointer;
transition: background-color 0.15s;
font-size: 0.875rem;
}
.dropdown-item:hover {
background-color: rgba(0, 0, 0, 0.05);
}
:global(.dark) .dropdown-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.dropdown-item .name {
flex: 1;
text-align: left;
font-weight: 500;
}
.dropdown-item.active {
font-weight: 600;
}
.check {
flex-shrink: 0;
}
</style>
<LanguageSelector
{currentLocale}
{supportedLocales}
onLocaleChange={handleLocaleChange}
{isDark}
primaryColor={getPrimaryColor()}
/>

View file

@ -1,13 +1,33 @@
import { format, isToday, isYesterday } from 'date-fns';
import { de } from 'date-fns/locale';
/**
* Memoro Formatters
* Re-exports shared utilities and adds Memoro-specific helpers
*/
// Re-export date/time utilities from shared package
export {
formatTimestamp,
formatShortDate,
formatRelativeTime,
isToday,
isYesterday,
type LocaleKey
} from '@manacore/shared-utils';
// Re-export format utilities from shared package (with explicit names for clarity)
export {
formatDuration as formatDurationCompact,
parseDuration,
formatDurationWithUnits,
formatDurationHumanReadable
} from '@manacore/shared-utils';
/**
* Format duration from seconds to human-readable string with units
* Format duration from seconds to human-readable string with German units
*
* Examples:
* - 45 seconds "45 Sekunden"
* - 90 seconds "1:30 Minuten"
* - 3665 seconds "1:01:05 Stunden"
* This is the Memoro-specific format that displays:
* - "45 Sekunden" for seconds only
* - "1:30 Minuten" for minutes and seconds
* - "1:01:05 Stunden" for hours, minutes, and seconds
*/
export function formatDuration(durationSeconds: number): string {
if (!durationSeconds || durationSeconds === 0) return '0 Sekunden';
@ -36,25 +56,3 @@ export function formatDuration(durationSeconds: number): string {
export function getMemooDuration(memo: any): number {
return memo.source?.duration_seconds || memo.source?.duration || 0;
}
/**
* Format timestamp to localized German format
*
* Examples:
* - Today "Heute, 14:30"
* - Yesterday "Gestern, 14:30"
* - Other "15. März 2024, 14:30"
*/
export function formatTimestamp(date: Date | string): string {
const dateObj = typeof date === 'string' ? new Date(date) : date;
if (isToday(dateObj)) {
return `Heute, ${format(dateObj, 'HH:mm')}`;
}
if (isYesterday(dateObj)) {
return `Gestern, ${format(dateObj, 'HH:mm')}`;
}
return format(dateObj, 'd. MMMM yyyy, HH:mm', { locale: de });
}

View file

@ -1,155 +1,23 @@
/**
* IndexedDB Cache for persistent storage of signed URLs
* Survives page reloads and provides better performance
* Memoro IndexedDB Cache
* Uses shared cache utilities with Memoro-specific configuration
*/
const DB_NAME = 'memoro-cache';
const DB_VERSION = 1;
const STORE_NAME = 'audio-urls';
import { createCache } from '@manacore/shared-utils';
interface CachedUrl {
id: string;
url: string;
expires: number;
createdAt: number;
}
// Create Memoro-specific URL cache
const memoroUrlCache = createCache<string>({
dbName: 'memoro-cache',
storeName: 'audio-urls',
version: 1
});
let dbPromise: Promise<IDBDatabase> | null = null;
// Re-export with Memoro's original function names for backward compatibility
export const getCachedUrl = memoroUrlCache.get.bind(memoroUrlCache);
export const setCachedUrl = memoroUrlCache.set.bind(memoroUrlCache);
export const deleteCachedUrl = memoroUrlCache.delete.bind(memoroUrlCache);
export const cleanupExpiredUrls = memoroUrlCache.cleanupExpired.bind(memoroUrlCache);
export const clearAllCachedUrls = memoroUrlCache.clear.bind(memoroUrlCache);
/**
* Initialize IndexedDB connection
*/
function getDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
if (typeof indexedDB === 'undefined') {
reject(new Error('IndexedDB not available'));
return;
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
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(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
store.createIndex('expires', 'expires', { unique: false });
}
};
});
return dbPromise;
}
/**
* Get cached URL from IndexedDB
*/
export async function getCachedUrl(id: string): Promise<string | null> {
try {
const db = await getDB();
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
return new Promise((resolve) => {
const request = store.get(id);
request.onsuccess = () => {
const result = request.result as CachedUrl | undefined;
if (result && Date.now() < result.expires) {
resolve(result.url);
} else {
resolve(null);
}
};
request.onerror = () => {
console.error('Error getting cached URL:', request.error);
resolve(null);
};
});
} catch {
return null;
}
}
/**
* Store URL in IndexedDB cache
*/
export async function setCachedUrl(id: string, url: string, expiresInMs: number): Promise<void> {
try {
const db = await getDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const entry: CachedUrl = {
id,
url,
expires: Date.now() + expiresInMs,
createdAt: Date.now()
};
store.put(entry);
} catch (error) {
console.error('Error caching URL:', error);
}
}
/**
* Delete cached URL from IndexedDB
*/
export async function deleteCachedUrl(id: string): Promise<void> {
try {
const db = await getDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
store.delete(id);
} catch (error) {
console.error('Error deleting cached URL:', error);
}
}
/**
* Clean up expired entries from IndexedDB
* Should be called periodically
*/
export async function cleanupExpiredUrls(): Promise<void> {
try {
const db = await getDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
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 URLs:', error);
}
}
/**
* Clear all cached URLs
*/
export async function clearAllCachedUrls(): Promise<void> {
try {
const db = await getDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
store.clear();
} catch (error) {
console.error('Error clearing cache:', error);
}
}
// Also export the cache instance for advanced usage
export { memoroUrlCache };

View file

@ -1,79 +1,26 @@
/**
* Keyboard Shortcuts Utility
* Provides centralized keyboard shortcut handling for the application
* Memoro Keyboard Shortcuts
* Re-exports shared utilities and adds Memoro-specific shortcuts
*/
export interface ShortcutAction {
key: string;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
meta?: boolean; // Command key on Mac
description: string;
action: () => void;
preventDefault?: boolean;
}
// Re-export all keyboard shortcut utilities from shared package
export {
type ShortcutAction,
type ShortcutGroup,
matchesShortcut,
formatShortcut,
formatShortcutMac,
createShortcutHandler,
createShortcuts,
shortcuts,
isMac,
getPlatformShortcut
} from '@manacore/shared-utils';
export interface ShortcutGroup {
name: string;
shortcuts: ShortcutAction[];
}
import type { ShortcutAction, ShortcutGroup } from '@manacore/shared-utils';
/**
* 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
*/
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('+');
}
/**
* Create keyboard shortcut handler
*/
export function createShortcutHandler(shortcuts: ShortcutAction[]) {
return (event: KeyboardEvent) => {
// Don't handle shortcuts if user is typing in an input
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;
}
}
};
}
/**
* Default memo panel shortcuts
* Memoro-specific memo panel shortcuts
*/
export function getMemoPanelShortcuts(actions: {
onEdit?: () => void;
@ -212,23 +159,3 @@ export function getMemoPanelShortcuts(actions: {
return shortcuts;
}
/**
* Svelte action for keyboard shortcuts
*/
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);
}
};
}

View file

@ -15,6 +15,7 @@
"type-check": "tsc --noEmit"
},
"devDependencies": {
"svelte": "^5.43.14",
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,241 @@
<script lang="ts">
import type { LanguageCode, LanguageInfo } from '../languages';
import { LANGUAGES, getLanguageInfo } from '../languages';
interface Props {
/** Current locale */
currentLocale: string;
/** Supported locales for this app */
supportedLocales: readonly string[] | string[];
/** Callback when locale changes */
onLocaleChange: (locale: string) => void;
/** Whether dark mode is active */
isDark?: boolean;
/** Primary color for check mark (optional) */
primaryColor?: string;
}
let {
currentLocale,
supportedLocales,
onLocaleChange,
isDark = false,
primaryColor = '#6366f1'
}: Props = $props();
let isOpen = $state(false);
// Get language info with fallback
function getLanguageDisplay(code: string): { flag: string; name: string; code: string } {
const info = getLanguageInfo(code);
if (info) {
return {
flag: info.emoji,
name: info.nativeName,
code: code.toUpperCase().substring(0, 2)
};
}
return {
flag: '🌐',
name: code,
code: code.toUpperCase().substring(0, 2)
};
}
function handleLocaleChange(newLocale: string) {
onLocaleChange(newLocale);
isOpen = false;
}
function toggleDropdown() {
isOpen = !isOpen;
}
// Close dropdown when clicking outside
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.language-selector')) {
isOpen = false;
}
}
$effect(() => {
if (isOpen) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
let currentLang = $derived(getLanguageDisplay(currentLocale));
</script>
<div class="language-selector">
<button
onclick={toggleDropdown}
class="language-button"
class:active={isOpen}
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.6)'};
border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'};
color: {isDark ? '#ffffff' : '#000000'};"
>
<span class="flag">{currentLang.flag}</span>
<span class="code">{currentLang.code}</span>
<svg
class="chevron"
class:rotate={isOpen}
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
>
<path d="M2 4l4 4 4-4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
{#if isOpen}
<div
class="dropdown"
style="background-color: {isDark ? '#1E1E1E' : '#ffffff'};
border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'};
box-shadow: 0 4px 12px rgba(0, 0, 0, {isDark ? '0.4' : '0.15'});"
>
{#each supportedLocales as locale}
{@const lang = getLanguageDisplay(locale)}
<button
onclick={() => handleLocaleChange(locale)}
class="dropdown-item"
class:active={currentLocale === locale}
style="color: {isDark ? '#ffffff' : '#000000'};"
>
<span class="flag">{lang.flag}</span>
<span class="name">{lang.name}</span>
{#if currentLocale === locale}
<svg
class="check"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke={primaryColor}
>
<path
d="M3 8l3 3 7-7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<style>
.language-selector {
position: relative;
display: inline-block;
}
.language-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
border: 1px solid;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(10px);
}
.language-button:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.language-button.active {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.flag {
font-size: 1.125rem;
line-height: 1;
}
.code {
font-weight: 600;
letter-spacing: 0.025em;
}
.chevron {
transition: transform 0.2s;
margin-left: 0.25rem;
}
.chevron.rotate {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
min-width: 160px;
border-radius: 0.75rem;
border: 1px solid;
overflow: hidden;
z-index: 100;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dropdown-item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: none;
cursor: pointer;
transition: background-color 0.15s;
font-size: 0.875rem;
}
.dropdown-item:hover {
background-color: rgba(0, 0, 0, 0.05);
}
:global(.dark) .dropdown-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.dropdown-item .name {
flex: 1;
text-align: left;
font-weight: 500;
}
.dropdown-item.active {
font-weight: 600;
}
.check {
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1 @@
export { default as LanguageSelector } from './LanguageSelector.svelte';

View file

@ -44,3 +44,6 @@ export {
getCommonTranslations,
mergeWithCommon,
} from './translations/common';
// Components
export { LanguageSelector } from './components';

View file

@ -1,9 +1,9 @@
{
"compilerOptions": {
"target": "ES2022",
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
@ -12,7 +12,8 @@
"declarationMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
"noEmit": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]

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

199
pnpm-lock.yaml generated
View file

@ -309,7 +309,7 @@ importers:
version: 17.0.7(expo@54.0.25)(react@19.1.0)
expo-router:
specifier: ~6.0.14
version: 6.0.15(ja35odoob22us44jnpjqapckaa)
version: 6.0.15(evxcyavfmgswt4zg3ii4wlqsdm)
expo-secure-store:
specifier: ~15.0.7
version: 15.0.7(expo@54.0.25)
@ -412,7 +412,7 @@ importers:
version: 7.28.5
'@testing-library/react-native':
specifier: ^13.3.3
version: 13.3.3(jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
version: 13.3.3(jest@29.5.0)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
'@types/jest':
specifier: ^29.5.12
version: 29.5.14
@ -430,10 +430,10 @@ importers:
version: 10.0.0
jest:
specifier: ^29.2.1
version: 29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
version: 29.5.0(@types/node@18.15.11)(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3))
jest-expo:
specifier: ~54.0.13
version: 54.0.13(@babel/core@7.28.5)(expo@54.0.25)(jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(webpack@5.100.2)
version: 54.0.13(@babel/core@7.28.5)(expo@54.0.25)(jest@29.5.0)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(webpack@5.100.2)
patch-package:
specifier: ^8.0.0
version: 8.0.1
@ -501,9 +501,9 @@ importers:
'@sveltejs/vite-plugin-svelte':
specifier: ^6.2.1
version: 6.2.1(svelte@5.43.14)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
'@tailwindcss/postcss':
specifier: ^4.1.17
version: 4.1.17
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))
autoprefixer:
specifier: ^10.4.22
version: 10.4.22(postcss@8.5.6)
@ -517,8 +517,8 @@ importers:
specifier: ^4.3.3
version: 4.3.4(picomatch@4.0.3)(svelte@5.43.14)(typescript@5.9.3)
tailwindcss:
specifier: ^4.1.17
version: 4.1.17
specifier: ^3.4.17
version: 3.4.18(tsx@4.20.6)(yaml@2.8.1)
typescript:
specifier: ^5.9.3
version: 5.9.3
@ -1012,12 +1012,9 @@ importers:
'@sveltejs/vite-plugin-svelte':
specifier: ^6.2.1
version: 6.2.1(svelte@5.43.14)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
'@tailwindcss/postcss':
specifier: ^4.1.17
version: 4.1.17
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.1.17)
version: 0.5.19(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))
autoprefixer:
specifier: ^10.4.22
version: 10.4.22(postcss@8.5.6)
@ -1031,8 +1028,8 @@ importers:
specifier: ^4.3.3
version: 4.3.4(picomatch@4.0.3)(svelte@5.43.14)(typescript@5.9.3)
tailwindcss:
specifier: ^4.1.17
version: 4.1.17
specifier: ^3.4.17
version: 3.4.18(tsx@4.20.6)(yaml@2.8.1)
typescript:
specifier: ^5.9.3
version: 5.9.3
@ -1574,6 +1571,18 @@ importers:
specifier: ^5.7.3
version: 5.9.3
packages/shared-branding:
devDependencies:
svelte:
specifier: ^5.0.0
version: 5.43.14
svelte-check:
specifier: ^4.0.0
version: 4.3.4(picomatch@4.0.3)(svelte@5.43.14)(typescript@5.9.3)
typescript:
specifier: ^5.7.3
version: 5.9.3
packages/shared-config:
dependencies:
zod:
@ -1586,6 +1595,9 @@ importers:
packages/shared-i18n:
devDependencies:
svelte:
specifier: ^5.43.14
version: 5.43.14
typescript:
specifier: ^5.7.3
version: 5.9.3
@ -14819,7 +14831,7 @@ snapshots:
wrap-ansi: 7.0.0
ws: 8.18.3
optionalDependencies:
expo-router: 6.0.15(nbbplg4zewzlp5oy3zff3m2jw4)
expo-router: 6.0.15(evxcyavfmgswt4zg3ii4wlqsdm)
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@ -15782,41 +15794,6 @@ snapshots:
- supports-color
- ts-node
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))':
dependencies:
'@jest/console': 29.7.0
'@jest/reporters': 29.7.0
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 22.19.1
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-resolve-dependencies: 29.7.0
jest-runner: 29.7.0
jest-runtime: 29.7.0
jest-snapshot: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
jest-watcher: 29.7.0
micromatch: 4.0.8
pretty-format: 29.7.0
slash: 3.0.0
strip-ansi: 6.0.1
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
- ts-node
'@jest/core@30.2.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))':
dependencies:
'@jest/console': 30.2.0
@ -17835,12 +17812,7 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.18(tsx@4.20.6)(yaml@2.8.1)
'@tailwindcss/typography@0.5.19(tailwindcss@4.1.17)':
dependencies:
postcss-selector-parser: 6.0.10
tailwindcss: 4.1.17
'@testing-library/react-native@13.3.3(jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)':
'@testing-library/react-native@13.3.3(jest@29.5.0)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
jest-matcher-utils: 30.2.0
picocolors: 1.1.1
@ -17850,7 +17822,7 @@ snapshots:
react-test-renderer: 19.1.0(react@19.1.0)
redent: 3.0.0
optionalDependencies:
jest: 29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
jest: 29.5.0(@types/node@18.15.11)(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3))
'@testing-library/react-native@13.3.3(jest@30.2.0)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
@ -20101,21 +20073,6 @@ snapshots:
- supports-color
- ts-node
create-jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
create-require@1.1.1: {}
cross-fetch@3.2.0:
@ -20700,7 +20657,7 @@ snapshots:
'@typescript-eslint/eslint-plugin': 8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.1(jiti@2.6.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-expo: 1.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1))
@ -20774,7 +20731,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@ -20799,14 +20756,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.1(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
@ -20878,7 +20835,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.1(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@ -21473,7 +21430,7 @@ snapshots:
expo-device@8.0.9(expo@54.0.25):
dependencies:
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
ua-parser-js: 0.7.41
expo-document-picker@14.0.7(expo@54.0.25):
@ -21528,7 +21485,7 @@ snapshots:
expo-image-loader@6.0.0(expo@54.0.25):
dependencies:
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-image-picker@17.0.8(expo@54.0.13):
dependencies:
@ -21537,7 +21494,7 @@ snapshots:
expo-image-picker@17.0.8(expo@54.0.25):
dependencies:
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-image-loader: 6.0.0(expo@54.0.25)
expo-image@3.0.10(expo@54.0.25)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
@ -21604,7 +21561,7 @@ snapshots:
expo-localization@17.0.7(expo@54.0.25)(react@19.1.0):
dependencies:
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react: 19.1.0
rtl-detect: 1.1.2
@ -21722,7 +21679,7 @@ snapshots:
- '@types/react-dom'
- supports-color
expo-router@6.0.15(ja35odoob22us44jnpjqapckaa):
expo-router@6.0.15(evxcyavfmgswt4zg3ii4wlqsdm):
dependencies:
'@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@expo/schema-utils': 0.1.7
@ -21756,7 +21713,7 @@ snapshots:
vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
optionalDependencies:
'@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@testing-library/react-native': 13.3.3(jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
'@testing-library/react-native': 13.3.3(jest@29.5.0)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
react-dom: 19.1.0(react@19.1.0)
react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@ -21866,7 +21823,7 @@ snapshots:
expo-secure-store@15.0.7(expo@54.0.25):
dependencies:
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-server@1.0.4: {}
@ -21877,7 +21834,7 @@ snapshots:
expo-splash-screen@31.0.11(expo@54.0.25):
dependencies:
'@expo/prebuild-config': 54.0.6(expo@54.0.25)
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
transitivePeerDependencies:
- supports-color
@ -23486,25 +23443,6 @@ snapshots:
- supports-color
- ts-node
jest-cli@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
exit: 0.1.2
import-local: 3.2.0
jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
jest-cli@30.2.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
dependencies:
'@jest/core': 30.2.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
@ -23586,37 +23524,6 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
dependencies:
'@babel/core': 7.28.5
'@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.28.5)
chalk: 4.1.2
ci-info: 3.9.0
deepmerge: 4.3.1
glob: 7.2.3
graceful-fs: 4.2.11
jest-circus: 29.7.0
jest-environment-node: 29.7.0
jest-get-type: 29.6.3
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-runner: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
micromatch: 4.0.8
parse-json: 5.2.0
pretty-format: 29.7.0
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 22.19.1
ts-node: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
jest-config@30.2.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
dependencies:
'@babel/core': 7.28.5
@ -23722,7 +23629,7 @@ snapshots:
jest-util: 30.2.0
jest-validate: 30.2.0
jest-expo@54.0.13(@babel/core@7.28.5)(expo@54.0.25)(jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(webpack@5.100.2):
jest-expo@54.0.13(@babel/core@7.28.5)(expo@54.0.25)(jest@29.5.0)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(webpack@5.100.2):
dependencies:
'@expo/config': 12.0.10
'@expo/json-file': 10.0.7
@ -23733,7 +23640,7 @@ snapshots:
jest-environment-jsdom: 29.7.0
jest-snapshot: 29.7.0
jest-watch-select-projects: 2.0.0
jest-watch-typeahead: 2.2.1(jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))
jest-watch-typeahead: 2.2.1(jest@29.5.0)
json5: 2.2.3
lodash: 4.17.21
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)
@ -24094,11 +24001,11 @@ snapshots:
chalk: 3.0.0
prompts: 2.4.2
jest-watch-typeahead@2.2.1(jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))):
jest-watch-typeahead@2.2.1(jest@29.5.0):
dependencies:
ansi-escapes: 6.2.1
chalk: 4.1.2
jest: 29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
jest: 29.5.0(@types/node@18.15.11)(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3))
jest-regex-util: 29.6.3
jest-watcher: 29.7.0
slash: 5.1.0
@ -24160,18 +24067,6 @@ snapshots:
- supports-color
- ts-node
jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
'@jest/types': 29.6.3
import-local: 3.2.0
jest-cli: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
jest@30.2.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
dependencies:
'@jest/core': 30.2.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))