diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 26110b0..7bf7f26 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { version as appVersion } from '../../package.json'; import { useLiveQuery } from 'dexie-react-hooks'; -import { db, addStorageLocation, deleteStorageLocation, exportData, exportCSV, importData, ImportResult } from '../lib/db'; +import { db, addStorageLocation, deleteStorageLocation, exportData, exportCSV, importData, loadImportedImages, ImportResult } from '../lib/db'; import { requestNotificationPermission, getNotificationPermissionStatus } from '../lib/notifications'; import { useDarkMode } from '../hooks/useDarkMode'; import { usePWAInstall } from '../hooks/usePWAInstall'; @@ -28,6 +28,7 @@ import { ChevronUp, Info, Globe, + Loader2, } from 'lucide-react'; const LANGUAGES = [ @@ -47,6 +48,7 @@ export function Settings() { const allProducts = useLiveQuery(() => db.products.toArray()) ?? []; const [newLocation, setNewLocation] = useState(''); const [importStatus, setImportStatus] = useState<{ message: string; type: 'success' | 'warning' | 'error' } | null>(null); + const [imageLoadProgress, setImageLoadProgress] = useState<{ loaded: number; total: number } | null>(null); const [showImpressum, setShowImpressum] = useState(false); const [showDatenschutz, setShowDatenschutz] = useState(false); const [showAGB, setShowAGB] = useState(false); @@ -80,17 +82,30 @@ export function Settings() { downloadFile(data, `preptrack-export-${new Date().toISOString().split('T')[0]}.csv`, 'text/csv;charset=utf-8'); } + async function startImageLoading(productIds: number[]) { + if (productIds.length === 0) return; + setImageLoadProgress({ loaded: 0, total: productIds.length }); + await loadImportedImages(productIds, (loaded, total) => { + setImageLoadProgress({ loaded, total }); + }); + setImageLoadProgress(null); + } + async function handleImport(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; try { const text = await file.text(); - const count = await importData(text); - setImportStatus({ message: t('import.success', { count }), type: 'success' }); + const result = await importData(text); + setImportStatus({ message: t('import.success', { count: result.imported }), type: 'success' }); + // Bilder im Hintergrund nachladen + startImageLoading(result.productsNeedingImages); } catch (err) { if (err instanceof ImportResult) { setImportStatus({ message: err.message, type: 'warning' }); + // Auch bei teilweisem Import Bilder nachladen + startImageLoading(err.productsNeedingImages); } else { setImportStatus({ message: t('import.error', { message: err instanceof Error ? err.message : t('import.importFailed') }), type: 'error' }); } @@ -374,6 +389,27 @@ export function Settings() { {importStatus.message}

)} + + {imageLoadProgress && ( +
+
+ + + {t('import.loadingImages', { + loaded: imageLoadProgress.loaded, + total: imageLoadProgress.total, + defaultValue: 'Lade Produktbilder… {{loaded}} / {{total}}', + })} + +
+
+
+
+
+ )}
diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index c57a269..3ab6849 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -284,7 +284,8 @@ "import": { "success": "تم استيراد {{count}} منتجات بنجاح.", "error": "خطأ: {{message}}", - "importFailed": "فشل الاستيراد" + "importFailed": "فشل الاستيراد", + "loadingImages": "جاري تحميل صور المنتجات… {{loaded}} / {{total}}" }, "notifications": { "expiredTitle": "{{name}} انتهت صلاحيته!", diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 90c2a0f..bc8212e 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -284,7 +284,8 @@ "import": { "success": "{{count}} Produkte erfolgreich importiert.", "error": "Fehler: {{message}}", - "importFailed": "Import fehlgeschlagen" + "importFailed": "Import fehlgeschlagen", + "loadingImages": "Lade Produktbilder… {{loaded}} / {{total}}" }, "notifications": { "expiredTitle": "{{name}} ist abgelaufen!", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 98819de..1ecec87 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -284,7 +284,8 @@ "import": { "success": "{{count}} products successfully imported.", "error": "Error: {{message}}", - "importFailed": "Import failed" + "importFailed": "Import failed", + "loadingImages": "Loading product images… {{loaded}} / {{total}}" }, "notifications": { "expiredTitle": "{{name}} has expired!", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index e677c93..0880949 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -284,7 +284,8 @@ "import": { "success": "{{count}} produits import\u00e9s avec succ\u00e8s.", "error": "Erreur : {{message}}", - "importFailed": "L'importation a \u00e9chou\u00e9" + "importFailed": "L'importation a \u00e9chou\u00e9", + "loadingImages": "Chargement des images… {{loaded}} / {{total}}" }, "notifications": { "expiredTitle": "{{name}} a expir\u00e9 !", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 10645df..104a99c 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -284,7 +284,8 @@ "import": { "success": "{{count}} prodotti importati con successo.", "error": "Errore: {{message}}", - "importFailed": "Importazione fallita" + "importFailed": "Importazione fallita", + "loadingImages": "Caricamento immagini prodotti… {{loaded}} / {{total}}" }, "notifications": { "expiredTitle": "{{name}} \u00e8 scaduto!", diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index d96a316..6682c66 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -284,7 +284,8 @@ "import": { "success": "{{count}} produtos importados com sucesso.", "error": "Erro: {{message}}", - "importFailed": "Importação falhou" + "importFailed": "Importação falhou", + "loadingImages": "Carregando imagens dos produtos… {{loaded}} / {{total}}" }, "notifications": { "expiredTitle": "{{name}} venceu!", diff --git a/src/lib/db.ts b/src/lib/db.ts index e16475c..2021bd3 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,7 +1,7 @@ import Dexie, { type Table } from 'dexie'; import { version as appVersion } from '../../package.json'; import i18n from '../i18n/i18n'; -import { getLocale } from './utils'; +import { getLocale, lookupBarcode, fetchAndCompressImage } from './utils'; import type { Product, StorageLocation, @@ -200,7 +200,14 @@ export async function exportCSV(): Promise { return BOM + [headers.join(';'), ...rows.map((r) => r.join(';'))].join('\r\n'); } -export async function importData(jsonString: string): Promise { +export interface ImportDataResult { + imported: number; + skipped: number; + /** IDs von importierten Produkten die einen Barcode aber kein Foto haben */ + productsNeedingImages: number[]; +} + +export async function importData(jsonString: string): Promise { const t = i18n.t.bind(i18n); let data: Record; try { @@ -219,6 +226,7 @@ export async function importData(jsonString: string): Promise { let imported = 0; let skipped = 0; + const productsNeedingImages: number[] = []; await db.transaction( 'rw', @@ -273,12 +281,13 @@ export async function importData(jsonString: string): Promise { // Clean up photo field - don't import placeholder markers const rawPhoto = product.photo; const photo = rawPhoto && rawPhoto !== '[FOTO]' && typeof rawPhoto === 'string' ? rawPhoto : undefined; + const barcode = typeof product.barcode === 'string' ? product.barcode : undefined; const now = new Date().toISOString(); // Only import known fields to prevent injection of unexpected data - await db.products.add({ + const newId = await db.products.add({ name: String(product.name), - barcode: typeof product.barcode === 'string' ? product.barcode : undefined, + barcode, category: typeof product.category === 'string' ? product.category as Product['category'] : 'sonstiges', storageLocation: typeof product.storageLocation === 'string' ? product.storageLocation : 'Keller', quantity: typeof product.quantity === 'number' ? product.quantity : 1, @@ -293,6 +302,11 @@ export async function importData(jsonString: string): Promise { updatedAt: typeof product.updatedAt === 'string' ? product.updatedAt : now, }); imported++; + + // Produkt hat Barcode aber kein Foto → Bild nachladen + if (barcode && !photo) { + productsNeedingImages.push(newId); + } } // Import consumption logs @@ -305,23 +319,65 @@ export async function importData(jsonString: string): Promise { ); if (skipped > 0) { - throw new ImportResult(imported, skipped); + throw new ImportResult(imported, skipped, productsNeedingImages); + } + + return { imported, skipped, productsNeedingImages }; +} + +/** + * Lädt Produktbilder im Hintergrund per Barcode von Open Food Facts. + * Ruft für jedes Produkt lookupBarcode auf, holt das Bild und speichert es. + * @param productIds - IDs der Produkte die ein Bild brauchen + * @param onProgress - Callback für Fortschritt (geladen, gesamt) + */ +export async function loadImportedImages( + productIds: number[], + onProgress?: (loaded: number, total: number) => void +): Promise { + let loaded = 0; + const total = productIds.length; + + for (const id of productIds) { + try { + const product = await db.products.get(id); + if (!product?.barcode || product.photo) { + onProgress?.(++loaded, total); + continue; + } + + const result = await lookupBarcode(product.barcode); + if (result?.imageUrl) { + const photo = await fetchAndCompressImage(result.imageUrl); + if (photo) { + await db.products.update(id, { + photo, + updatedAt: new Date().toISOString(), + }); + } + } + } catch { + // Einzelnes Bild fehlgeschlagen — weiter mit dem nächsten + } + onProgress?.(++loaded, total); } - return imported; + return loaded; } // Custom class to pass both imported and skipped counts export class ImportResult extends Error { imported: number; skipped: number; + productsNeedingImages: number[]; - constructor(imported: number, skipped: number) { + constructor(imported: number, skipped: number, productsNeedingImages: number[] = []) { const t = i18n.t.bind(i18n); const msg = t('dbErrors.importResult', { imported, skipped }); super(msg); this.name = 'ImportResult'; this.imported = imported; this.skipped = skipped; + this.productsNeedingImages = productsNeedingImages; } }