chore: initial project setup with backend, frontend, and infrastructure
Some checks failed
CI / backend (push) Failing after 31s
CI / frontend (push) Successful in 40s
CI / docker (push) Has been skipped

Add complete NexaPantry application structure including:
- Docker Compose configuration with PostgreSQL, Redis, FastAPI backend, worker, frontend and Caddy
- Environment configuration template with database, auth, and service settings
- GitHub Actions CI workflow for backend/frontend linting, testing, auditing and Docker builds
- AGPL-3.0 license and comprehensive README with setup, development, and security documentation
- Backend
This commit is contained in:
2026-06-04 10:26:38 +02:00
commit 3792ca55e7
74 changed files with 13417 additions and 0 deletions

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginxinc/nginx-unprivileged:1.27-alpine AS runtime
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080
HEALTHCHECK CMD wget -qO- http://127.0.0.1:8080/ || exit 1

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh
},
rules: {
...reactHooks.configs.recommended.rules,
'react-hooks/set-state-in-effect': 'off',
'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': 'off'
}
}
);

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0f766e" />
<link rel="icon" href="/icons/icon.svg" />
<title>NexaPantry</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

26
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,26 @@
worker_processes auto;
pid /tmp/nginx.pid;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
server_tokens off;
access_log off;
server {
listen 8080;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}
}

10085
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
frontend/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "nexapantry-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"typecheck": "tsc -b --pretty false",
"test": "vitest"
},
"dependencies": {
"@zxing/browser": "^0.1.5",
"framer-motion": "^11.3.0",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"vite-plugin-pwa": "^1.3.0"
},
"devDependencies": {
"@eslint/js": "^9.8.0",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@types/node": "^25.9.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.4.19",
"eslint": "^9.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.4.9",
"jsdom": "^24.1.1",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"typescript": "^5.9.3",
"typescript-eslint": "^8.0.0",
"vite": "^8.0.16",
"vitest": "^4.1.8"
}
}

View File

@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="NexaPantry">
<rect width="512" height="512" rx="96" fill="#0f766e"/>
<path fill="#f8fafc" d="M150 152h212l-18 248H168l-18-248Zm34 40 12 168h120l12-168H184Z"/>
<path fill="#d9f99d" d="M198 126c24-42 78-54 116-20-16 46-70 64-116 20Z"/>
</svg>

After

Width:  |  Height:  |  Size: 333 B

View File

@@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders loading shell', () => {
render(<App />);
expect(screen.getByText('NexaPantry')).toBeInTheDocument();
});

54
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { useEffect, useMemo, useState } from 'react';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { I18nContext } from './contexts/I18nContext';
import { dictionaries } from './i18n/dictionaries';
import { AppLayout, type View } from './components/Layout';
import { getSetupStatus, LoginScreen, SetupWizard } from './pages/AuthScreens';
import { HomePage } from './pages/HomePage';
import { InventoryPage } from './pages/InventoryPage';
import { ShoppingPage } from './pages/ShoppingPage';
import { ProfilePage } from './pages/ProfilePage';
import { Onboarding } from './pages/Onboarding';
import { ScannerPage } from './pages/ScannerPage';
import { AdminPage } from './pages/AdminPage';
import type { Language, SetupStatus } from './types';
function languageFromBrowser(): Language {
return navigator.language.toLowerCase().startsWith('de') ? 'de' : 'en';
}
function Shell() {
const { user, loading } = useAuth();
const [view, setView] = useState<View>('home');
if (loading) return <div className="grid min-h-dvh place-items-center dark:bg-gray-950 dark:text-white">NexaPantry</div>;
if (!user) return <LoginScreen />;
if (!user.onboarding_completed) return <Onboarding />;
return (
<AppLayout view={view} setView={setView}>
{view === 'home' && <HomePage goAdmin={() => setView('admin')} />}
{view === 'inventory' && <InventoryPage />}
{view === 'scanner' && <ScannerPage />}
{view === 'shopping' && <ShoppingPage />}
{view === 'profile' && <ProfilePage />}
{view === 'admin' && <AdminPage />}
</AppLayout>
);
}
function AppInner() {
const [status, setStatus] = useState<SetupStatus | null>(null);
const [language, setLanguage] = useState<Language>(languageFromBrowser());
useEffect(() => { void getSetupStatus().then((value) => { setStatus(value); setLanguage((value.instance?.language as Language) ?? languageFromBrowser()); }); }, []);
const i18n = useMemo(() => ({ language, t: (key: string) => dictionaries[language][key] ?? key }), [language]);
if (!status) return <div className="grid min-h-dvh place-items-center dark:bg-gray-950 dark:text-white">NexaPantry</div>;
return (
<I18nContext.Provider value={i18n}>
{status.needs_setup ? <SetupWizard onDone={() => window.location.reload()} /> : <AuthProvider><Shell /></AuthProvider>}
</I18nContext.Provider>
);
}
export default function App() {
return <AppInner />;
}

View File

@@ -0,0 +1,27 @@
const API_BASE = '/api';
function csrf() {
return document.cookie
.split('; ')
.find((part) => part.startsWith('np_csrf='))
?.split('=')[1];
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (!(init.body instanceof FormData)) headers.set('Content-Type', 'application/json');
const token = csrf();
if (token) headers.set('X-CSRF-Token', decodeURIComponent(token));
const response = await fetch(`${API_BASE}${path}`, {
...init,
headers,
credentials: 'include'
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || response.statusText);
}
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}

View File

@@ -0,0 +1,52 @@
import type { InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react';
export function Field(props: InputHTMLAttributes<HTMLInputElement> & { label: string }) {
const { label, ...input } = props;
return (
<label className="grid gap-1 text-sm font-medium text-gray-700 dark:text-gray-200">
<span>{label}</span>
<input {...input} className="focus-ring rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 shadow-sm dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100" />
</label>
);
}
export function SelectField(props: SelectHTMLAttributes<HTMLSelectElement> & { label: string }) {
const { label, children, ...select } = props;
return (
<label className="grid gap-1 text-sm font-medium text-gray-700 dark:text-gray-200">
<span>{label}</span>
<select {...select} className="focus-ring rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 shadow-sm dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100">
{children}
</select>
</label>
);
}
export function TextAreaField(props: TextareaHTMLAttributes<HTMLTextAreaElement> & { label: string }) {
const { label, ...textarea } = props;
return (
<label className="grid gap-1 text-sm font-medium text-gray-700 dark:text-gray-200">
<span>{label}</span>
<textarea {...textarea} className="focus-ring min-h-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 shadow-sm dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100" />
</label>
);
}
export function Button({ variant = 'primary', ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'secondary' | 'danger' }) {
const variants = {
primary: 'bg-teal-700 text-white hover:bg-teal-800',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700',
danger: 'bg-rose-700 text-white hover:bg-rose-800'
};
return <button {...props} className={`focus-ring rounded-lg px-4 py-2 font-semibold shadow-sm transition ${variants[variant]} ${props.className ?? ''}`} />;
}
export function Panel({ children, title }: { children: React.ReactNode; title?: string }) {
return (
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
{title ? <h2 className="mb-3 text-lg font-semibold text-gray-950 dark:text-gray-50">{title}</h2> : null}
{children}
</section>
);
}

View File

@@ -0,0 +1,59 @@
import { AnimatePresence, motion } from 'framer-motion';
import { Home, ListPlus, PackageSearch, Plus, ScanLine, ShoppingCart, UserRound } from 'lucide-react';
import { useState } from 'react';
import { useI18n } from '../contexts/I18nContext';
export type View = 'home' | 'inventory' | 'shopping' | 'profile' | 'admin' | 'scanner';
export function AppLayout({ view, setView, children }: { view: View; setView: (view: View) => void; children: React.ReactNode }) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const items = [
{ id: 'home' as View, icon: Home, label: t('home') },
{ id: 'inventory' as View, icon: PackageSearch, label: t('inventory') },
{ id: 'shopping' as View, icon: ShoppingCart, label: t('shopping') },
{ id: 'profile' as View, icon: UserRound, label: t('profile') }
];
return (
<div className="min-h-dvh bg-slate-50 pb-24 text-gray-950 dark:bg-gray-950 dark:text-gray-50">
<main className="mx-auto w-full max-w-5xl px-4 py-5">{children}</main>
<AnimatePresence>
{open && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-30 bg-gray-950/30 backdrop-blur-sm" onClick={() => setOpen(false)} />
)}
</AnimatePresence>
<AnimatePresence>
{open && (
<motion.div initial={{ y: 24, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: 24, opacity: 0 }} className="fixed bottom-24 left-1/2 z-40 grid w-64 -translate-x-1/2 gap-2">
<button className="focus-ring flex items-center gap-3 rounded-lg bg-white px-4 py-3 text-left font-semibold shadow-xl dark:bg-gray-900" onClick={() => { setOpen(false); setView('inventory'); }}>
<ListPlus size={20} /> {t('manualAdd')}
</button>
<button className="focus-ring flex items-center gap-3 rounded-lg bg-white px-4 py-3 text-left font-semibold shadow-xl dark:bg-gray-900" onClick={() => { setOpen(false); setView('scanner'); }}>
<ScanLine size={20} /> {t('scanBarcode')}
</button>
</motion.div>
)}
</AnimatePresence>
<nav className="fixed inset-x-0 bottom-0 z-50 border-t border-gray-200 bg-white/95 px-3 pb-[env(safe-area-inset-bottom)] pt-2 shadow-2xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/95">
<div className="mx-auto grid max-w-lg grid-cols-5 items-center">
{items.slice(0, 2).map((item) => <NavButton key={item.id} active={view === item.id} icon={item.icon} label={item.label} onClick={() => setView(item.id)} />)}
<button aria-label={t('add')} onClick={() => setOpen((value) => !value)} className="focus-ring mx-auto -mt-8 grid size-16 place-items-center rounded-full bg-teal-700 text-white shadow-xl transition hover:bg-teal-800">
<motion.span animate={{ rotate: open ? 45 : 0 }}><Plus size={34} /></motion.span>
</button>
{items.slice(2).map((item) => <NavButton key={item.id} active={view === item.id} icon={item.icon} label={item.label} onClick={() => setView(item.id)} />)}
</div>
</nav>
</div>
);
}
function NavButton({ active, icon: Icon, label, onClick }: { active: boolean; icon: typeof Home; label: string; onClick: () => void }) {
return (
<button onClick={onClick} className={`focus-ring grid justify-items-center gap-1 rounded-lg px-1 py-2 text-xs font-semibold ${active ? 'text-teal-700 dark:text-teal-300' : 'text-gray-500 dark:text-gray-400'}`}>
<Icon size={22} />
<span>{label}</span>
</button>
);
}

View File

@@ -0,0 +1,40 @@
import { useState } from 'react';
import { api } from '../api/client';
import { useAuth } from '../contexts/AuthContext';
import { useI18n } from '../contexts/I18nContext';
import type { Product } from '../types';
import { Button, Field, TextAreaField } from './Forms';
const empty = { name: '', barcode: '', brand: '', category: 'Other', location: 'Pantry', quantity: 1, unit: 'pcs', expires_at: '', min_quantity: 0, notes: '', image_url: '' };
export function ProductForm({ initial, onSaved }: { initial?: Partial<Product>; onSaved: () => void }) {
const { activeHome } = useAuth();
const { t } = useI18n();
const [form, setForm] = useState({ ...empty, ...initial });
if (!activeHome) return null;
const set = (key: string, value: string | number) => setForm((current) => ({ ...current, [key]: value }));
return (
<form className="grid gap-3" onSubmit={async (event) => {
event.preventDefault();
const body = { ...form, expires_at: form.expires_at || null, quantity: Number(form.quantity), min_quantity: Number(form.min_quantity) };
await api(`/homes/${activeHome.id}/products${initial?.id ? `/${initial.id}` : ''}`, { method: initial?.id ? 'PATCH' : 'POST', body: JSON.stringify(body) });
setForm(empty);
onSaved();
}}>
<div className="grid gap-3 sm:grid-cols-2">
<Field label={t('productName')} value={form.name} required onChange={(e) => set('name', e.target.value)} />
<Field label={t('barcode')} value={form.barcode ?? ''} onChange={(e) => set('barcode', e.target.value)} />
<Field label={t('brand')} value={form.brand ?? ''} onChange={(e) => set('brand', e.target.value)} />
<Field label={t('category')} value={form.category} onChange={(e) => set('category', e.target.value)} />
<Field label={t('location')} value={form.location} onChange={(e) => set('location', e.target.value)} />
<Field label={t('quantity')} type="number" min="0" step="0.01" value={form.quantity} onChange={(e) => set('quantity', Number(e.target.value))} />
<Field label={t('unit')} value={form.unit} onChange={(e) => set('unit', e.target.value)} />
<Field label={t('expiresAt')} type="date" value={form.expires_at ?? ''} onChange={(e) => set('expires_at', e.target.value)} />
<Field label={t('minQuantity')} type="number" min="0" step="0.01" value={form.min_quantity} onChange={(e) => set('min_quantity', Number(e.target.value))} />
</div>
<TextAreaField label={t('notes')} value={form.notes ?? ''} onChange={(e) => set('notes', e.target.value)} />
<Button type="submit">{t('save')}</Button>
</form>
);
}

View File

@@ -0,0 +1,82 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { api } from '../api/client';
import type { Home, Theme, User } from '../types';
type AuthState = {
user: User | null;
homes: Home[];
activeHome: Home | null;
loading: boolean;
refresh: () => Promise<void>;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
setActiveHomeId: (id: string) => void;
};
const AuthContext = createContext<AuthState | null>(null);
function applyTheme(theme: Theme | undefined) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const dark = theme === 'dark' || (theme === 'system' && prefersDark);
document.documentElement.classList.toggle('dark', dark);
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [homes, setHomes] = useState<Home[]>([]);
const [activeHomeId, setActiveHomeId] = useState<string>(() => localStorage.getItem('np_home') ?? '');
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const me = await api<User>('/auth/me');
setUser(me);
applyTheme(me.theme);
const loadedHomes = await api<Home[]>('/homes');
setHomes(loadedHomes);
if (!activeHomeId && loadedHomes[0]) setActiveHomeId(loadedHomes[0].id);
} catch {
setUser(null);
setHomes([]);
} finally {
setLoading(false);
}
}, [activeHomeId]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (activeHomeId) localStorage.setItem('np_home', activeHomeId);
}, [activeHomeId]);
const value = useMemo<AuthState>(() => ({
user,
homes,
activeHome: homes.find((home) => home.id === activeHomeId) ?? homes[0] ?? null,
loading,
refresh,
login: async (email, password) => {
const me = await api<User>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) });
setUser(me);
applyTheme(me.theme);
await refresh();
},
logout: async () => {
await api('/auth/logout', { method: 'POST' });
setUser(null);
setHomes([]);
},
setActiveHomeId
}), [activeHomeId, homes, loading, refresh, user]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('AuthProvider missing');
return context;
}

View File

@@ -0,0 +1,13 @@
import { createContext, useContext } from 'react';
import { dictionaries } from '../i18n/dictionaries';
import type { Language } from '../types';
export const I18nContext = createContext<{ language: Language; t: (key: string) => string }>({
language: 'de',
t: (key) => dictionaries.de[key] ?? key
});
export function useI18n() {
return useContext(I18nContext);
}

View File

@@ -0,0 +1,139 @@
import type { Language } from '../types';
export const dictionaries: Record<Language, Record<string, string>> = {
de: {
setupTitle: 'NexaPantry einrichten',
setupSubtitle: 'Erstelle den ersten Instance Admin und sichere die Instanz ab.',
name: 'Name',
email: 'E-Mail',
password: 'Passwort',
language: 'Sprache',
theme: 'Theme',
publicUrl: 'Öffentliche Server-URL',
instanceName: 'Instanzname',
timezone: 'Zeitzone',
completeSetup: 'Setup abschließen',
login: 'Anmelden',
logout: 'Abmelden',
home: 'Home',
inventory: 'Bestand',
add: 'Hinzufügen',
shopping: 'Einkauf',
profile: 'Profil',
admin: 'Admin',
dashboard: 'Dashboard',
users: 'Benutzer',
homes: 'Homes',
mail: 'Mail',
security: 'Sicherheit',
notifications: 'Benachrichtigungen',
system: 'System',
logs: 'Logs',
backupRestore: 'Backup/Restore',
categories: 'Kategorien',
locations: 'Orte',
manualAdd: 'Manuell hinzufügen',
scanBarcode: 'Barcode scannen',
productName: 'Produktname',
barcode: 'Barcode',
brand: 'Marke',
category: 'Kategorie',
location: 'Lagerort',
quantity: 'Menge',
unit: 'Einheit',
expiresAt: 'Ablaufdatum',
minQuantity: 'Mindestbestand',
notes: 'Notizen',
save: 'Speichern',
cancel: 'Abbrechen',
ok: 'haltbar',
soon: 'läuft bald ab',
expired: 'abgelaufen',
addToShopping: 'Zur Einkaufsliste',
recipes: 'Rezepte',
onboardingTitle: 'Willkommen in NexaPantry',
onboardingDone: 'Tutorial abschließen',
skip: 'Überspringen',
createHome: 'Home erstellen',
joinHome: 'Home per Code beitreten',
joinCode: 'Join-Code',
smtpHost: 'SMTP Host',
smtpPort: 'SMTP Port',
smtpUser: 'SMTP User',
smtpPassword: 'SMTP Passwort',
senderAddress: 'Absender-Adresse',
senderName: 'Absender-Name',
testMail: 'Testmail senden',
light: 'Light',
dark: 'Dark',
systemTheme: 'System'
},
en: {
setupTitle: 'Set up NexaPantry',
setupSubtitle: 'Create the first instance admin and secure the server.',
name: 'Name',
email: 'E-mail',
password: 'Password',
language: 'Language',
theme: 'Theme',
publicUrl: 'Public server URL',
instanceName: 'Instance name',
timezone: 'Timezone',
completeSetup: 'Complete setup',
login: 'Sign in',
logout: 'Sign out',
home: 'Home',
inventory: 'Inventory',
add: 'Add',
shopping: 'Shopping',
profile: 'Profile',
admin: 'Admin',
dashboard: 'Dashboard',
users: 'Users',
homes: 'Homes',
mail: 'Mail',
security: 'Security',
notifications: 'Notifications',
system: 'System',
logs: 'Logs',
backupRestore: 'Backup/Restore',
categories: 'Categories',
locations: 'Locations',
manualAdd: 'Add manually',
scanBarcode: 'Scan barcode',
productName: 'Product name',
barcode: 'Barcode',
brand: 'Brand',
category: 'Category',
location: 'Location',
quantity: 'Quantity',
unit: 'Unit',
expiresAt: 'Expiry date',
minQuantity: 'Minimum stock',
notes: 'Notes',
save: 'Save',
cancel: 'Cancel',
ok: 'good',
soon: 'expires soon',
expired: 'expired',
addToShopping: 'Add to shopping',
recipes: 'Recipes',
onboardingTitle: 'Welcome to NexaPantry',
onboardingDone: 'Finish tutorial',
skip: 'Skip',
createHome: 'Create home',
joinHome: 'Join home by code',
joinCode: 'Join code',
smtpHost: 'SMTP host',
smtpPort: 'SMTP port',
smtpUser: 'SMTP user',
smtpPassword: 'SMTP password',
senderAddress: 'Sender address',
senderName: 'Sender name',
testMail: 'Send test mail',
light: 'Light',
dark: 'Dark',
systemTheme: 'System'
}
};

11
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,91 @@
import { DatabaseBackup, Mail, ScrollText, Shield, UsersRound } from 'lucide-react';
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { Button, Field, Panel, SelectField } from '../components/Forms';
import { useI18n } from '../contexts/I18nContext';
import type { User } from '../types';
export function AdminPage() {
const { t } = useI18n();
const [tab, setTab] = useState('dashboard');
const tabs = [
['dashboard', t('dashboard'), Shield],
['users', t('users'), UsersRound],
['mail', t('mail'), Mail],
['logs', t('logs'), ScrollText],
['backup', t('backupRestore'), DatabaseBackup]
] as const;
return (
<div className="grid gap-4">
<h1 className="text-3xl font-bold">{t('admin')}</h1>
<div className="flex gap-2 overflow-x-auto rounded-lg bg-gray-200 p-1 dark:bg-gray-800">
{tabs.map(([id, label, Icon]) => <button key={id} className={`focus-ring flex items-center gap-2 rounded-md px-3 py-2 text-sm font-semibold ${tab === id ? 'bg-white shadow dark:bg-gray-950' : ''}`} onClick={() => setTab(id)}><Icon size={18} />{label}</button>)}
</div>
{tab === 'dashboard' && <AdminDashboard />}
{tab === 'users' && <AdminUsers />}
{tab === 'mail' && <AdminMail />}
{tab === 'logs' && <AdminLogs />}
{tab === 'backup' && <AdminBackup />}
</div>
);
}
function AdminDashboard() {
const [stats, setStats] = useState<Record<string, number>>({});
useEffect(() => { void api<Record<string, number>>('/admin/dashboard').then(setStats); }, []);
return <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">{Object.entries(stats).map(([key, value]) => <Panel key={key}><p className="text-sm text-gray-500">{key}</p><p className="text-3xl font-bold">{value}</p></Panel>)}</div>;
}
function AdminUsers() {
const { t } = useI18n();
const [users, setUsers] = useState<User[]>([]);
const [form, setForm] = useState({ email: '', name: '', role: 'user' });
const load = async () => setUsers(await api<User[]>('/admin/users'));
useEffect(() => { void load(); }, []);
return (
<div className="grid gap-4">
<Panel title={t('users')}>
<form className="grid gap-3 sm:grid-cols-4" onSubmit={async (event) => { event.preventDefault(); await api('/admin/users', { method: 'POST', body: JSON.stringify({ ...form, send_invite: true }) }); setForm({ email: '', name: '', role: 'user' }); await load(); }}>
<Field label={t('email')} value={form.email} required onChange={(e) => setForm({ ...form, email: e.target.value })} />
<Field label={t('name')} value={form.name} required onChange={(e) => setForm({ ...form, name: e.target.value })} />
<SelectField label={t('security')} value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}><option value="user">User</option><option value="instance_admin">Instance Admin</option></SelectField>
<Button className="self-end">{t('add')}</Button>
</form>
</Panel>
<div className="grid gap-2">
{users.map((user) => <div key={user.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-white p-3 shadow-sm dark:bg-gray-900"><div><p className="font-semibold">{user.name}</p><p className="text-sm text-gray-500">{user.email} · {user.instance_role}</p></div><Button variant="secondary" onClick={async () => { await api(`/admin/users/${user.id}`, { method: 'PATCH', body: JSON.stringify({ is_active: !user.is_active }) }); await load(); }}>{user.is_active ? 'Disable' : 'Enable'}</Button></div>)}
</div>
</div>
);
}
function AdminMail() {
const { t } = useI18n();
const [form, setForm] = useState({ smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '', sender_address: '', sender_name: 'NexaPantry', use_tls: true, use_starttls: true });
const set = (key: string, value: string | number | boolean) => setForm((current) => ({ ...current, [key]: value }));
useEffect(() => { void api<Record<string, string | number | boolean | null>>('/admin/mail').then((data) => setForm((current) => ({ ...current, ...data, smtp_password: '' }))); }, []);
return (
<Panel title={t('mail')}>
<form className="grid gap-3 sm:grid-cols-2" onSubmit={async (event) => { event.preventDefault(); await api('/admin/mail', { method: 'PUT', body: JSON.stringify(form) }); }}>
<Field label={t('smtpHost')} value={form.smtp_host} onChange={(e) => set('smtp_host', e.target.value)} />
<Field label={t('smtpPort')} type="number" value={form.smtp_port} onChange={(e) => set('smtp_port', Number(e.target.value))} />
<Field label={t('smtpUser')} value={form.smtp_user} onChange={(e) => set('smtp_user', e.target.value)} />
<Field label={t('smtpPassword')} type="password" value={form.smtp_password} onChange={(e) => set('smtp_password', e.target.value)} />
<Field label={t('senderAddress')} type="email" value={form.sender_address} onChange={(e) => set('sender_address', e.target.value)} />
<Field label={t('senderName')} value={form.sender_name} onChange={(e) => set('sender_name', e.target.value)} />
<Button>{t('save')}</Button>
</form>
</Panel>
);
}
function AdminLogs() {
const [logs, setLogs] = useState<Array<Record<string, string>>>([]);
useEffect(() => { void api<Array<Record<string, string>>>('/admin/logs').then(setLogs); }, []);
return <Panel><div className="grid gap-2">{logs.map((row, index) => <code key={index} className="rounded bg-gray-100 p-2 text-xs dark:bg-gray-800">{row.created_at} {row.action} {row.target_type}:{row.target_id}</code>)}</div></Panel>;
}
function AdminBackup() {
return <Panel title="Backup/Restore"><pre className="overflow-auto rounded-lg bg-gray-950 p-4 text-sm text-lime-200">docker compose exec postgres pg_dump -U $POSTGRES_USER $POSTGRES_DB &gt; backup.sql{'\n'}docker compose exec -T postgres psql -U $POSTGRES_USER $POSTGRES_DB &lt; backup.sql</pre></Panel>;
}

View File

@@ -0,0 +1,67 @@
import { useState } from 'react';
import { api } from '../api/client';
import { useAuth } from '../contexts/AuthContext';
import { useI18n } from '../contexts/I18nContext';
import type { Language, SetupStatus, Theme } from '../types';
import { Button, Field, Panel, SelectField } from '../components/Forms';
export function SetupWizard({ onDone }: { onDone: () => void }) {
const { t } = useI18n();
const [form, setForm] = useState({ name: '', email: '', password: '', language: 'de' as Language, theme: 'system' as Theme, public_url: window.location.origin, instance_name: 'NexaPantry', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone });
const set = (key: string, value: string) => setForm((current) => ({ ...current, [key]: value }));
return (
<div className="grid min-h-dvh place-items-center bg-slate-50 p-4 dark:bg-gray-950">
<Panel>
<form className="grid max-w-2xl gap-4" onSubmit={async (event) => {
event.preventDefault();
await api('/setup/complete', { method: 'POST', body: JSON.stringify(form) });
onDone();
}}>
<div>
<h1 className="text-3xl font-bold text-gray-950 dark:text-white">{t('setupTitle')}</h1>
<p className="mt-2 text-gray-600 dark:text-gray-300">{t('setupSubtitle')}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Field label={t('name')} required value={form.name} onChange={(e) => set('name', e.target.value)} />
<Field label={t('email')} required type="email" value={form.email} onChange={(e) => set('email', e.target.value)} />
<Field label={t('password')} required type="password" minLength={12} value={form.password} onChange={(e) => set('password', e.target.value)} />
<SelectField label={t('language')} value={form.language} onChange={(e) => set('language', e.target.value)}>
<option value="de">Deutsch</option><option value="en">English</option>
</SelectField>
<SelectField label={t('theme')} value={form.theme} onChange={(e) => set('theme', e.target.value)}>
<option value="light">{t('light')}</option><option value="dark">{t('dark')}</option><option value="system">{t('systemTheme')}</option>
</SelectField>
<Field label={t('timezone')} required value={form.timezone} onChange={(e) => set('timezone', e.target.value)} />
<Field label={t('publicUrl')} required value={form.public_url} onChange={(e) => set('public_url', e.target.value)} />
<Field label={t('instanceName')} required value={form.instance_name} onChange={(e) => set('instance_name', e.target.value)} />
</div>
<Button type="submit">{t('completeSetup')}</Button>
</form>
</Panel>
</div>
);
}
export function LoginScreen() {
const { login } = useAuth();
const { t } = useI18n();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
return (
<div className="grid min-h-dvh place-items-center bg-slate-50 p-4 dark:bg-gray-950">
<Panel>
<form className="grid w-full max-w-sm gap-4" onSubmit={async (event) => { event.preventDefault(); await login(email, password); }}>
<h1 className="text-3xl font-bold text-gray-950 dark:text-white">NexaPantry</h1>
<Field label={t('email')} type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
<Field label={t('password')} type="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
<Button type="submit">{t('login')}</Button>
</form>
</Panel>
</div>
);
}
export async function getSetupStatus(): Promise<SetupStatus> {
return api<SetupStatus>('/setup/status');
}

View File

@@ -0,0 +1,60 @@
import { Bell, ChefHat, PackageCheck, ShieldCheck, ShoppingBasket } from 'lucide-react';
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { Panel } from '../components/Forms';
import { useAuth } from '../contexts/AuthContext';
import { useI18n } from '../contexts/I18nContext';
export function HomePage({ goAdmin }: { goAdmin: () => void }) {
const { activeHome, user } = useAuth();
const { t } = useI18n();
const [stats, setStats] = useState({ products: 0, soon: 0, shopping: 0, notifications: 0 });
const [recipes, setRecipes] = useState<Array<{ id: string; name: string; matchedIngredients: string[] }>>([]);
useEffect(() => {
if (!activeHome) return;
void Promise.all([
api<Array<{ status: string }>>(`/homes/${activeHome.id}/products`),
api<Array<{ checked: boolean }>>(`/homes/${activeHome.id}/shopping`),
api<Array<{ read_at: string | null }>>('/notifications'),
api<Array<{ id: string; name: string; matchedIngredients: string[] }>>(`/homes/${activeHome.id}/recipes`)
]).then(([products, shopping, notifications, loadedRecipes]) => {
setStats({ products: products.length, soon: products.filter((p) => p.status !== 'ok').length, shopping: shopping.filter((i) => !i.checked).length, notifications: notifications.filter((n) => !n.read_at).length });
setRecipes(loadedRecipes);
});
}, [activeHome]);
return (
<div className="grid gap-4">
<header className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-teal-700 dark:text-teal-300">{activeHome?.name ?? t('home')}</p>
<h1 className="text-3xl font-bold">{user?.name}</h1>
</div>
{user?.instance_role === 'instance_admin' ? <button onClick={goAdmin} className="focus-ring rounded-lg bg-gray-900 p-3 text-white dark:bg-white dark:text-gray-950"><ShieldCheck /></button> : null}
</header>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat icon={<PackageCheck />} label={t('inventory')} value={stats.products} />
<Stat icon={<Bell />} label={t('soon')} value={stats.soon} />
<Stat icon={<ShoppingBasket />} label={t('shopping')} value={stats.shopping} />
<Stat icon={<Bell />} label={t('notifications')} value={stats.notifications} />
</div>
<Panel title={t('recipes')}>
<div className="grid gap-2">
{recipes.map((recipe) => (
<div key={recipe.id} className="flex items-center gap-3 rounded-lg bg-lime-50 p-3 dark:bg-lime-950/30">
<ChefHat className="text-teal-700" />
<div>
<p className="font-semibold">{recipe.name}</p>
<p className="text-sm text-gray-600 dark:text-gray-300">{recipe.matchedIngredients.join(', ')}</p>
</div>
</div>
))}
</div>
</Panel>
</div>
);
}
function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }) {
return <div className="rounded-lg bg-white p-4 shadow-sm dark:bg-gray-900"><div className="text-teal-700">{icon}</div><p className="mt-3 text-2xl font-bold">{value}</p><p className="text-sm text-gray-500">{label}</p></div>;
}

View File

@@ -0,0 +1,59 @@
import { Grid2X2, MapPin } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../api/client';
import { Button, Panel } from '../components/Forms';
import { ProductForm } from '../components/ProductForm';
import { useAuth } from '../contexts/AuthContext';
import { useI18n } from '../contexts/I18nContext';
import type { Product } from '../types';
export function InventoryPage() {
const { activeHome } = useAuth();
const { t } = useI18n();
const [products, setProducts] = useState<Product[]>([]);
const [mode, setMode] = useState<'category' | 'location'>('category');
const load = useCallback(async () => {
if (activeHome) setProducts(await api<Product[]>(`/homes/${activeHome.id}/products`));
}, [activeHome]);
useEffect(() => { void load(); }, [load]);
const groups = useMemo(() => products.reduce<Record<string, Product[]>>((acc, product) => {
const key = mode === 'category' ? product.category : product.location;
acc[key] = [...(acc[key] ?? []), product];
return acc;
}, {}), [mode, products]);
if (!activeHome) return null;
return (
<div className="grid gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-3xl font-bold">{t('inventory')}</h1>
<div className="flex rounded-lg bg-gray-200 p-1 dark:bg-gray-800">
<button className={`rounded-md px-3 py-2 ${mode === 'category' ? 'bg-white shadow dark:bg-gray-950' : ''}`} onClick={() => setMode('category')}><Grid2X2 size={18} /></button>
<button className={`rounded-md px-3 py-2 ${mode === 'location' ? 'bg-white shadow dark:bg-gray-950' : ''}`} onClick={() => setMode('location')}><MapPin size={18} /></button>
</div>
</div>
<Panel title={t('manualAdd')}><ProductForm onSaved={() => void load()} /></Panel>
{Object.entries(groups).map(([group, rows]) => (
<section key={group} className="grid gap-2">
<h2 className="text-xl font-semibold">{group}</h2>
{rows.map((product) => <ProductRow key={product.id} product={product} homeId={activeHome.id} reload={load} />)}
</section>
))}
</div>
);
}
function ProductRow({ product, homeId, reload }: { product: Product; homeId: string; reload: () => Promise<void> }) {
const { t } = useI18n();
const colors = { ok: 'border-l-emerald-500', soon: 'border-l-amber-500', expired: 'border-l-rose-600' };
return (
<div className={`rounded-lg border border-l-4 bg-white p-3 shadow-sm dark:border-gray-800 dark:bg-gray-900 ${colors[product.status]}`}>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-semibold">{product.name}</p>
<p className="text-sm text-gray-500">{product.quantity} {product.unit} · {product.location} · {product.expires_at ?? '—'} · {t(product.status)}</p>
</div>
<Button variant="secondary" onClick={async () => { await api(`/homes/${homeId}/products/${product.id}/add-to-shopping`, { method: 'POST' }); await reload(); }}>{t('addToShopping')}</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { Bell, Home, PackagePlus, ScanLine, ShoppingBasket, UsersRound } from 'lucide-react';
import { api } from '../api/client';
import { Button, Panel } from '../components/Forms';
import { useAuth } from '../contexts/AuthContext';
import { useI18n } from '../contexts/I18nContext';
export function Onboarding() {
const { refresh } = useAuth();
const { t } = useI18n();
const steps = [
['manualAdd', PackagePlus],
['scanBarcode', ScanLine],
['expiresAt', Bell],
['shopping', ShoppingBasket],
['categories', Home],
['homes', UsersRound],
['notifications', Bell]
] as const;
const finish = async () => {
await api('/auth/me', { method: 'PATCH', body: JSON.stringify({ onboarding_completed: true }) });
await refresh();
};
return (
<div className="grid min-h-dvh place-items-center bg-slate-50 p-4 dark:bg-gray-950">
<Panel>
<div className="grid max-w-lg gap-4">
<h1 className="text-3xl font-bold">{t('onboardingTitle')}</h1>
<div className="grid gap-2">
{steps.map(([key, Icon]) => <div key={key} className="flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-gray-800"><Icon className="text-teal-700" /> <span className="font-medium">{t(key)}</span></div>)}
</div>
<div className="flex gap-2">
<Button onClick={finish}>{t('onboardingDone')}</Button>
<Button variant="secondary" onClick={finish}>{t('skip')}</Button>
</div>
</div>
</Panel>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { useState } from 'react';
import { api } from '../api/client';
import { Button, Field, Panel, SelectField } from '../components/Forms';
import { useAuth } from '../contexts/AuthContext';
import { useI18n } from '../contexts/I18nContext';
export function ProfilePage() {
const { user, homes, activeHome, setActiveHomeId, logout, refresh } = useAuth();
const { t } = useI18n();
const [joinCode, setJoinCode] = useState('');
const [homeName, setHomeName] = useState('');
if (!user) return null;
return (
<div className="grid gap-4">
<h1 className="text-3xl font-bold">{t('profile')}</h1>
<Panel title={user.name}>
<div className="grid gap-3 sm:grid-cols-2">
<SelectField label={t('home')} value={activeHome?.id ?? ''} onChange={(e) => setActiveHomeId(e.target.value)}>
{homes.map((home) => <option key={home.id} value={home.id}>{home.name}</option>)}
</SelectField>
<Button variant="secondary" onClick={logout}>{t('logout')}</Button>
</div>
</Panel>
<Panel title={t('createHome')}>
<form className="flex gap-2" onSubmit={async (event) => { event.preventDefault(); await api('/homes', { method: 'POST', body: JSON.stringify({ name: homeName }) }); setHomeName(''); await refresh(); }}>
<Field label={t('home')} value={homeName} required onChange={(e) => setHomeName(e.target.value)} />
<Button className="self-end">{t('save')}</Button>
</form>
</Panel>
<Panel title={t('joinHome')}>
<form className="flex gap-2" onSubmit={async (event) => { event.preventDefault(); await api('/homes/join', { method: 'POST', body: JSON.stringify({ join_code: joinCode }) }); setJoinCode(''); await refresh(); }}>
<Field label={t('joinCode')} value={joinCode} required onChange={(e) => setJoinCode(e.target.value)} />
<Button className="self-end">{t('save')}</Button>
</form>
</Panel>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { BrowserMultiFormatReader } from '@zxing/browser';
import { ScanLine } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { api } from '../api/client';
import { Panel } from '../components/Forms';
import { ProductForm } from '../components/ProductForm';
import { useAuth } from '../contexts/AuthContext';
import { useI18n } from '../contexts/I18nContext';
import type { Product } from '../types';
export function ScannerPage() {
const videoRef = useRef<HTMLVideoElement | null>(null);
const { activeHome } = useAuth();
const { t } = useI18n();
const [barcode, setBarcode] = useState('');
const [prefill, setPrefill] = useState<Partial<Product>>({});
useEffect(() => {
if (!activeHome || !videoRef.current) return;
const reader = new BrowserMultiFormatReader();
let controls: { stop: () => void } | undefined;
let stop = false;
reader.decodeFromVideoDevice(undefined, videoRef.current, async (result) => {
if (result && !stop) {
stop = true;
const code = result.getText();
setBarcode(code);
const lookup = await api<{ found: boolean; product?: Partial<Product> }>(`/homes/${activeHome.id}/products/lookup/${code}`);
setPrefill(lookup.product ?? { barcode: code });
}
}).then((scannerControls) => { controls = scannerControls; }).catch(() => undefined);
return () => { stop = true; controls?.stop(); };
}, [activeHome]);
return (
<div className="grid gap-4">
<h1 className="flex items-center gap-2 text-3xl font-bold"><ScanLine /> {t('scanBarcode')}</h1>
<video ref={videoRef} className="aspect-video w-full rounded-lg bg-gray-950 object-cover" muted playsInline />
{barcode ? <Panel title={`${t('barcode')}: ${barcode}`}><ProductForm initial={prefill} onSaved={() => setPrefill({})} /></Panel> : null}
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { Check, RotateCcw, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { api } from '../api/client';
import { Button, Field, Panel } from '../components/Forms';
import { useAuth } from '../contexts/AuthContext';
import { useI18n } from '../contexts/I18nContext';
import type { ShoppingItem } from '../types';
export function ShoppingPage() {
const { activeHome } = useAuth();
const { t } = useI18n();
const [items, setItems] = useState<ShoppingItem[]>([]);
const [name, setName] = useState('');
const load = useCallback(async () => {
if (activeHome) setItems(await api<ShoppingItem[]>(`/homes/${activeHome.id}/shopping`));
}, [activeHome]);
useEffect(() => { void load(); }, [load]);
if (!activeHome) return null;
return (
<div className="grid gap-4">
<h1 className="text-3xl font-bold">{t('shopping')}</h1>
<Panel>
<form className="flex gap-2" onSubmit={async (event) => {
event.preventDefault();
await api(`/homes/${activeHome.id}/shopping`, { method: 'POST', body: JSON.stringify({ name, category: 'Other', quantity: 1, unit: 'pcs' }) });
setName('');
await load();
}}>
<Field label={t('productName')} value={name} required onChange={(e) => setName(e.target.value)} />
<Button type="submit" className="self-end">{t('add')}</Button>
</form>
</Panel>
<div className="grid gap-2">
{items.map((item) => (
<div key={item.id} className="flex items-center justify-between gap-3 rounded-lg bg-white p-3 shadow-sm dark:bg-gray-900">
<button className="focus-ring rounded-lg p-2 text-teal-700" onClick={async () => { await api(`/homes/${activeHome.id}/shopping/${item.id}`, { method: 'PATCH', body: JSON.stringify({ checked: !item.checked }) }); await load(); }}><Check /></button>
<div className={`flex-1 ${item.checked ? 'text-gray-400 line-through' : ''}`}><p className="font-semibold">{item.name}</p><p className="text-sm text-gray-500">{item.quantity} {item.unit}</p></div>
<button className="focus-ring rounded-lg p-2" onClick={async () => { await api(`/homes/${activeHome.id}/shopping/${item.id}/move-to-inventory`, { method: 'POST' }); await load(); }}><RotateCcw /></button>
<button className="focus-ring rounded-lg p-2 text-rose-700" onClick={async () => { await api(`/homes/${activeHome.id}/shopping/${item.id}`, { method: 'DELETE' }); await load(); }}><Trash2 /></button>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.dark {
color-scheme: dark;
}
body {
margin: 0;
min-width: 320px;
background: #f8fafc;
}
.dark body {
background: #111827;
}
button, input, select, textarea {
font: inherit;
}
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-teal-600 focus:ring-offset-2 dark:focus:ring-offset-gray-900;
}

View File

@@ -0,0 +1,21 @@
import '@testing-library/jest-dom/vitest';
import { vi } from 'vitest';
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
});
globalThis.fetch = vi.fn(async () =>
new Response(JSON.stringify({ needs_setup: true, instance: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
);

View File

@@ -0,0 +1,57 @@
export type Theme = 'light' | 'dark' | 'system';
export type Language = 'de' | 'en';
export type User = {
id: string;
email: string;
name: string;
instance_role: 'instance_admin' | 'user';
language: Language;
theme: Theme;
timezone: string;
is_active: boolean;
onboarding_completed: boolean;
};
export type Home = {
id: string;
name: string;
expiry_warning_days: number;
daily_summary_enabled: boolean;
daily_summary_time: string;
role?: 'home_owner' | 'home_member' | 'read_only';
};
export type Product = {
id: string;
home_id: string;
name: string;
barcode?: string | null;
brand?: string | null;
category: string;
location: string;
quantity: number;
unit: string;
expires_at?: string | null;
min_quantity: number;
notes?: string | null;
image_url?: string | null;
status: 'ok' | 'soon' | 'expired';
};
export type ShoppingItem = {
id: string;
home_id: string;
name: string;
category: string;
quantity: number;
unit: string;
checked: boolean;
product_id?: string | null;
};
export type SetupStatus = {
needs_setup: boolean;
instance?: Record<string, unknown> | null;
};

View File

@@ -0,0 +1,19 @@
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
pantry: {
ink: '#172026',
teal: '#0f766e',
mint: '#d9f99d',
berry: '#be123c',
amber: '#d97706'
}
}
}
},
plugins: []
};

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "WebWorker", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["node", "vitest/globals"]
},
"include": ["src", "vite.config.ts"]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/app.test.tsx","./src/app.tsx","./src/main.tsx","./src/test-setup.ts","./src/api/client.ts","./src/components/forms.tsx","./src/components/layout.tsx","./src/components/productform.tsx","./src/contexts/authcontext.tsx","./src/contexts/i18ncontext.tsx","./src/i18n/dictionaries.ts","./src/pages/adminpage.tsx","./src/pages/authscreens.tsx","./src/pages/homepage.tsx","./src/pages/inventorypage.tsx","./src/pages/onboarding.tsx","./src/pages/profilepage.tsx","./src/pages/scannerpage.tsx","./src/pages/shoppingpage.tsx","./src/types/index.ts","./vite.config.ts"],"version":"5.9.3"}

46
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,46 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'NexaPantry',
short_name: 'NexaPantry',
description: 'Self-hosted pantry management for homes and shared households.',
theme_color: '#0f766e',
background_color: '#f8fafc',
display: 'standalone',
scope: '/',
start_url: '/',
icons: [
{ src: '/icons/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any maskable' }
]
},
workbox: {
navigateFallback: '/index.html',
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
runtimeCaching: [
{
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
handler: 'NetworkFirst',
options: { cacheName: 'nexapantry-api', networkTimeoutSeconds: 3 }
}
]
}
})
],
server: {
proxy: {
'/api': 'http://127.0.0.1:8000'
}
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test-setup.ts'
}
});