Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions src/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,6 +28,7 @@ import {
ChevronUp,
Info,
Globe,
Loader2,
} from 'lucide-react';

const LANGUAGES = [
Expand All @@ -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);
Expand Down Expand Up @@ -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<HTMLInputElement>) {
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' });
}
Expand Down Expand Up @@ -374,6 +389,27 @@ export function Settings() {
{importStatus.message}
</p>
)}

{imageLoadProgress && (
<div className="space-y-2 rounded-lg bg-blue-500/10 px-3 py-2">
<div className="flex items-center gap-2 text-sm text-blue-400">
<Loader2 size={16} className="animate-spin" />
<span>
{t('import.loadingImages', {
loaded: imageLoadProgress.loaded,
total: imageLoadProgress.total,
defaultValue: 'Lade Produktbilder… {{loaded}} / {{total}}',
})}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-primary-700">
<div
className="h-full rounded-full bg-blue-500 transition-all duration-300"
style={{ width: `${(imageLoadProgress.loaded / imageLoadProgress.total) * 100}%` }}
/>
</div>
</div>
)}
</div>
</section>

Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ar/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,8 @@
"import": {
"success": "تم استيراد {{count}} منتجات بنجاح.",
"error": "خطأ: {{message}}",
"importFailed": "فشل الاستيراد"
"importFailed": "فشل الاستيراد",
"loadingImages": "جاري تحميل صور المنتجات… {{loaded}} / {{total}}"
},
"notifications": {
"expiredTitle": "{{name}} انتهت صلاحيته!",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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!",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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!",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 !",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/it/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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!",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/pt/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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!",
Expand Down
70 changes: 63 additions & 7 deletions src/lib/db.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -200,7 +200,14 @@ export async function exportCSV(): Promise<string> {
return BOM + [headers.join(';'), ...rows.map((r) => r.join(';'))].join('\r\n');
}

export async function importData(jsonString: string): Promise<number> {
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<ImportDataResult> {
const t = i18n.t.bind(i18n);
let data: Record<string, unknown>;
try {
Expand All @@ -219,6 +226,7 @@ export async function importData(jsonString: string): Promise<number> {

let imported = 0;
let skipped = 0;
const productsNeedingImages: number[] = [];

await db.transaction(
'rw',
Expand Down Expand Up @@ -273,12 +281,13 @@ export async function importData(jsonString: string): Promise<number> {
// 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,
Expand All @@ -293,6 +302,11 @@ export async function importData(jsonString: string): Promise<number> {
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
Expand All @@ -305,23 +319,65 @@ export async function importData(jsonString: string): Promise<number> {
);

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<number> {
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;
}
}
Loading