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
158 changes: 144 additions & 14 deletions src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useMemo, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '../lib/db';
import { db, importData, loadImportedImages, ImportResult } from '../lib/db';
import { computeStats, getExpiryStatus, getDaysUntilExpiry, formatDate, formatDaysUntil } from '../lib/utils';
import { useAppStore } from '../store/useAppStore';
import type { ProductCategory } from '../types';
Expand All @@ -12,6 +12,14 @@ import {
PlusCircle,
TrendingDown,
ChevronRight,
Upload,
Camera,
Image,
BellRing,
WifiOff,
Lock,
HardDrive,
Loader2,
} from 'lucide-react';

const URGENT_STATUS_COLORS: Record<string, string> = {
Expand All @@ -34,6 +42,40 @@ export function Dashboard() {
const setPage = useAppStore((s) => s.setPage);
const products = useLiveQuery(() => db.products.toArray()) ?? [];
const { t } = useTranslation();
const [importStatus, setImportStatus] = useState<{ message: string; type: 'success' | 'warning' | 'error' } | null>(null);
const [imageLoadProgress, setImageLoadProgress] = useState<{ loaded: number; total: number } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

async function handleImport(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const result = await importData(text);
setImportStatus({ message: t('import.success', { count: result.imported }), type: 'success' });
if (result.productsNeedingImages.length > 0) {
setImageLoadProgress({ loaded: 0, total: result.productsNeedingImages.length });
await loadImportedImages(result.productsNeedingImages, (loaded, total) => {
setImageLoadProgress({ loaded, total });
});
setImageLoadProgress(null);
}
} catch (err) {
if (err instanceof ImportResult) {
setImportStatus({ message: err.message, type: 'warning' });
if (err.productsNeedingImages.length > 0) {
setImageLoadProgress({ loaded: 0, total: err.productsNeedingImages.length });
await loadImportedImages(err.productsNeedingImages, (loaded, total) => {
setImageLoadProgress({ loaded, total });
});
setImageLoadProgress(null);
}
} else {
setImportStatus({ message: t('import.error', { message: err instanceof Error ? err.message : t('import.importFailed') }), type: 'error' });
}
}
e.target.value = '';
}

const { stats, activeProducts, urgentProducts, categoryBreakdown, total } = useMemo(() => {
const s = computeStats(products);
Expand Down Expand Up @@ -66,21 +108,109 @@ export function Dashboard() {

if (activeProducts.length === 0) {
return (
<div className="flex min-h-[70vh] flex-col items-center justify-center text-center">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-primary-700 bg-primary-800">
<Package size={40} className="text-primary-600" />
<div className="space-y-5 pb-4">
{/* Header */}
<div className="text-center">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-2xl border border-green-500/30 bg-green-500/10">
<Package size={40} className="text-green-400" />
</div>
<h2 className="mt-4 text-2xl font-bold text-gray-100">{t('onboarding.title')}</h2>
<p className="mt-1 text-sm text-gray-400">{t('onboarding.subtitle')}</p>
</div>
<p className="mt-5 text-xl font-semibold text-gray-200">{t('dashboard.noProducts')}</p>
<p className="mt-2 max-w-xs text-sm text-gray-400">{t('dashboard.noProductsDesc')}</p>
<div className="mt-6 flex gap-3">
<button onClick={() => setPage('scanner')} className="flex items-center gap-2 rounded-xl bg-green-600 px-5 py-3 font-medium text-white hover:bg-green-500 active:scale-[0.98] transition-transform">
<ScanBarcode size={18} />
{t('dashboard.scan')}

{/* Schnellstart-Buttons */}
<div className="grid grid-cols-1 gap-2.5">
<button
onClick={() => setPage('scanner')}
className="flex items-center gap-3 rounded-xl border border-green-500/30 bg-green-500/10 p-4 text-start hover:bg-green-500/20 active:scale-[0.98] transition-transform"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-600/20">
<ScanBarcode size={20} className="text-green-400" />
</div>
<div>
<p className="text-sm font-medium text-gray-200">{t('onboarding.startScan')}</p>
<p className="text-xs text-gray-400">{t('onboarding.step1Desc')}</p>
</div>
</button>

<button
onClick={() => setPage('add')}
className="flex items-center gap-3 rounded-xl border border-blue-500/30 bg-blue-500/10 p-4 text-start hover:bg-blue-500/20 active:scale-[0.98] transition-transform"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-600/20">
<PlusCircle size={20} className="text-blue-400" />
</div>
<div>
<p className="text-sm font-medium text-gray-200">{t('onboarding.startManual')}</p>
<p className="text-xs text-gray-400">{t('dashboard.addManual')}</p>
</div>
</button>
<button onClick={() => setPage('add')} className="flex items-center gap-2 rounded-xl border border-primary-600 px-5 py-3 font-medium text-gray-300 hover:bg-primary-700">
<PlusCircle size={18} />
{t('dashboard.manual')}

<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-3 rounded-xl border border-orange-500/30 bg-orange-500/10 p-4 text-start hover:bg-orange-500/20 active:scale-[0.98] transition-transform"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-600/20">
<Upload size={20} className="text-orange-400" />
</div>
<div>
<p className="text-sm font-medium text-gray-200">{t('onboarding.startImport')}</p>
<p className="text-xs text-gray-400">{t('onboarding.step3Desc')}</p>
</div>
</button>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleImport}
className="hidden"
/>
</div>

{/* Import Status */}
{importStatus && (
<p className={`rounded-lg px-3 py-2 text-sm ${
importStatus.type === 'error' ? 'bg-red-500/10 text-red-400'
: importStatus.type === 'warning' ? 'bg-orange-500/10 text-orange-400'
: 'bg-green-500/10 text-green-400'
}`}>
{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 })}</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>
)}

{/* Feature-Übersicht */}
<div className="rounded-2xl border border-primary-700 bg-primary-800/60 p-4">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-400">{t('onboarding.features')}</h3>
<div className="space-y-2">
{[
{ icon: <WifiOff size={15} className="text-blue-400" />, text: t('onboarding.featureOffline') },
{ icon: <Camera size={15} className="text-green-400" />, text: t('onboarding.featureCamera') },
{ icon: <Image size={15} className="text-purple-400" />, text: t('onboarding.featureImages') },
{ icon: <BellRing size={15} className="text-yellow-400" />, text: t('onboarding.featureNotifications') },
{ icon: <HardDrive size={15} className="text-orange-400" />, text: t('onboarding.featureExport') },
{ icon: <Lock size={15} className="text-emerald-400" />, text: t('onboarding.featurePrivacy') },
].map((item, i) => (
<div key={i} className="flex items-start gap-2.5">
<span className="mt-0.5 shrink-0">{item.icon}</span>
<span className="text-xs text-gray-400">{item.text}</span>
</div>
))}
</div>
</div>
</div>
);
Expand Down
40 changes: 40 additions & 0 deletions src/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ import {
Info,
Globe,
Loader2,
Camera,
Image,
BellRing,
WifiOff,
Lock,
HardDrive,
} from 'lucide-react';

const LANGUAGES = [
Expand All @@ -49,6 +55,7 @@ export function Settings() {
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 [showInfo, setShowInfo] = useState(false);
const [showImpressum, setShowImpressum] = useState(false);
const [showDatenschutz, setShowDatenschutz] = useState(false);
const [showAGB, setShowAGB] = useState(false);
Expand Down Expand Up @@ -133,6 +140,39 @@ export function Settings() {
<h2 className="text-2xl font-bold text-gray-100">{t('settings.title')}</h2>
</div>

{/* Über PrepTrack / Info */}
<section className="rounded-xl border border-primary-700 bg-primary-800/60 p-4">
<button
onClick={() => setShowInfo(!showInfo)}
className="flex w-full items-center justify-between"
>
<h3 className="flex items-center gap-2 font-semibold text-gray-200">
<Info size={18} className="text-green-400" />
{t('onboarding.features')}
</h3>
{showInfo ? <ChevronUp size={18} className="text-gray-400" /> : <ChevronDown size={18} className="text-gray-400" />}
</button>
{showInfo && (
<div className="mt-4 space-y-3">
<div className="space-y-2.5">
{[
{ icon: <WifiOff size={16} className="text-blue-400" />, text: t('onboarding.featureOffline') },
{ icon: <Camera size={16} className="text-green-400" />, text: t('onboarding.featureCamera') },
{ icon: <Image size={16} className="text-purple-400" />, text: t('onboarding.featureImages') },
{ icon: <BellRing size={16} className="text-yellow-400" />, text: t('onboarding.featureNotifications') },
{ icon: <HardDrive size={16} className="text-orange-400" />, text: t('onboarding.featureExport') },
{ icon: <Lock size={16} className="text-emerald-400" />, text: t('onboarding.featurePrivacy') },
].map((item, i) => (
<div key={i} className="flex items-start gap-3 rounded-lg bg-primary-700/30 px-3 py-2.5">
<span className="mt-0.5 shrink-0">{item.icon}</span>
<span className="text-sm text-gray-300">{item.text}</span>
</div>
))}
</div>
</div>
)}
</section>

{/* Language */}
<section className="rounded-xl border border-primary-700 bg-primary-800/60 p-4">
<h3 className="mb-3 flex items-center gap-2 font-semibold text-gray-200">
Expand Down
22 changes: 22 additions & 0 deletions src/i18n/locales/ar/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,28 @@
"importResult": "تم استيراد {{imported}} منتجات، تم تخطي {{skipped}} (مكررة أو غير صالحة).",
"unknownProduct": "منتج غير معروف"
},
"onboarding": {
"title": "مرحباً بك في PrepTrack",
"subtitle": "مدير المخزون الرقمي الخاص بك",
"getStarted": "كيف تبدأ",
"step1Title": "إضافة المنتجات",
"step1Desc": "امسح الباركود أو أضف المنتجات يدوياً مع تاريخ الصلاحية والفئة وموقع التخزين.",
"step2Title": "تتبع تواريخ الصلاحية",
"step2Desc": "احصل على إشعارات تلقائية قبل 30 و14 و7 و3 ويوم واحد من انتهاء الصلاحية.",
"step3Title": "النسخ الاحتياطي والمزامنة",
"step3Desc": "صدّر بياناتك كنسخة احتياطية JSON. عند الاستيراد، يتم تحميل صور المنتجات تلقائياً عبر الباركود.",
"features": "الميزات",
"featureOffline": "أولوية العمل بدون إنترنت — يعمل بالكامل بدون اتصال",
"featureCamera": "ماسح باركود مع كشف تلقائي للكاميرا الرئيسية",
"featureImages": "يتم تحميل صور المنتجات تلقائياً من Open Food Facts",
"featureNotifications": "تذكيرات محلية بالصلاحية — لا حاجة للسحابة",
"featureExport": "نسخ احتياطي JSON وتصدير CSV لبياناتك",
"featurePrivacy": "100% خاص — جميع البيانات تبقى على جهازك",
"startScan": "مسح الباركود",
"startManual": "إضافة يدوية",
"startImport": "استيراد نسخة احتياطية",
"dismiss": "فهمت، هيا نبدأ!"
},
"common": {
"yes": "نعم",
"no": "لا"
Expand Down
22 changes: 22 additions & 0 deletions src/i18n/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,28 @@
"importResult": "{{imported}} Produkte importiert, {{skipped}} übersprungen (Duplikate oder ungültig).",
"unknownProduct": "Unbekanntes Produkt"
},
"onboarding": {
"title": "Willkommen bei PrepTrack",
"subtitle": "Dein digitaler Vorratsmanager",
"getStarted": "So startest du",
"step1Title": "Produkte erfassen",
"step1Desc": "Scanne Barcodes oder erfasse Produkte manuell mit MHD, Kategorie und Lagerort.",
"step2Title": "MHD im Blick",
"step2Desc": "Erhalte automatische Benachrichtigungen 30, 14, 7, 3 und 1 Tag vor Ablauf.",
"step3Title": "Backup & Sync",
"step3Desc": "Exportiere deine Daten als JSON-Backup. Beim Import werden Produktbilder automatisch per Barcode geladen.",
"features": "Funktionen",
"featureOffline": "Offline-first — funktioniert komplett ohne Internet",
"featureCamera": "Barcode-Scanner mit automatischer Hauptkamera-Erkennung",
"featureImages": "Produktbilder werden automatisch von Open Food Facts geladen",
"featureNotifications": "Lokale MHD-Erinnerungen — keine Cloud nötig",
"featureExport": "JSON-Backup & CSV-Export für deine Daten",
"featurePrivacy": "100% privat — alle Daten bleiben auf deinem Gerät",
"startScan": "Barcode scannen",
"startManual": "Manuell erfassen",
"startImport": "Backup importieren",
"dismiss": "Verstanden, los geht's!"
},
"common": {
"yes": "Ja",
"no": "Nein"
Expand Down
22 changes: 22 additions & 0 deletions src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,28 @@
"importResult": "{{imported}} products imported, {{skipped}} skipped (duplicates or invalid).",
"unknownProduct": "Unknown product"
},
"onboarding": {
"title": "Welcome to PrepTrack",
"subtitle": "Your digital supply manager",
"getStarted": "Getting started",
"step1Title": "Add products",
"step1Desc": "Scan barcodes or add products manually with expiry date, category and storage location.",
"step2Title": "Track expiry dates",
"step2Desc": "Get automatic notifications 30, 14, 7, 3 and 1 day before expiry.",
"step3Title": "Backup & Sync",
"step3Desc": "Export your data as JSON backup. On import, product images are automatically loaded via barcode.",
"features": "Features",
"featureOffline": "Offline-first — works completely without internet",
"featureCamera": "Barcode scanner with automatic main camera detection",
"featureImages": "Product images loaded automatically from Open Food Facts",
"featureNotifications": "Local expiry reminders — no cloud needed",
"featureExport": "JSON backup & CSV export for your data",
"featurePrivacy": "100% private — all data stays on your device",
"startScan": "Scan barcode",
"startManual": "Add manually",
"startImport": "Import backup",
"dismiss": "Got it, let's go!"
},
"common": {
"yes": "Yes",
"no": "No"
Expand Down
22 changes: 22 additions & 0 deletions src/i18n/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,28 @@
"importResult": "{{imported}} produits import\u00e9s, {{skipped}} ignor\u00e9(s) (doublons ou invalides).",
"unknownProduct": "Produit inconnu"
},
"onboarding": {
"title": "Bienvenue sur PrepTrack",
"subtitle": "Votre gestionnaire de provisions num\u00e9rique",
"getStarted": "Pour commencer",
"step1Title": "Ajouter des produits",
"step1Desc": "Scannez des codes-barres ou ajoutez des produits manuellement avec date de p\u00e9remption, cat\u00e9gorie et lieu de stockage.",
"step2Title": "Suivre les dates de p\u00e9remption",
"step2Desc": "Recevez des notifications automatiques 30, 14, 7, 3 et 1 jour avant l'expiration.",
"step3Title": "Sauvegarde et synchronisation",
"step3Desc": "Exportez vos donn\u00e9es en sauvegarde JSON. Lors de l'importation, les images des produits sont charg\u00e9es automatiquement via le code-barres.",
"features": "Fonctionnalit\u00e9s",
"featureOffline": "Hors ligne d'abord \u2014 fonctionne compl\u00e8tement sans internet",
"featureCamera": "Scanner de codes-barres avec d\u00e9tection automatique de la cam\u00e9ra principale",
"featureImages": "Images des produits charg\u00e9es automatiquement depuis Open Food Facts",
"featureNotifications": "Rappels locaux de p\u00e9remption \u2014 pas de cloud n\u00e9cessaire",
"featureExport": "Sauvegarde JSON et export CSV de vos donn\u00e9es",
"featurePrivacy": "100% priv\u00e9 \u2014 toutes les donn\u00e9es restent sur votre appareil",
"startScan": "Scanner un code-barres",
"startManual": "Ajouter manuellement",
"startImport": "Importer une sauvegarde",
"dismiss": "Compris, c'est parti !"
},
"common": {
"yes": "Oui",
"no": "Non"
Expand Down
Loading
Loading