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