419 lines
13 KiB
Markdown
419 lines
13 KiB
Markdown
# ⚙️ Luna Recipes — Tech-Stack
|
|
|
|
## 1. Übersicht
|
|
|
|
```
|
|
┌─────────────────────────────────────────┐
|
|
│ Frontend (PWA) │
|
|
│ React 19 + Vite + TailwindCSS v4 │
|
|
│ React Router v7 · Tanstack Query v5 │
|
|
├─────────────────────────────────────────┤
|
|
│ Backend (API) │
|
|
│ Fastify v5 + better-sqlite3 │
|
|
│ Bild-Upload: @fastify/multipart │
|
|
├─────────────────────────────────────────┤
|
|
│ Datenbank │
|
|
│ SQLite (WAL-Mode) + FTS5 │
|
|
└─────────────────────────────────────────┘
|
|
```
|
|
|
|
## 2. Frontend
|
|
|
|
### Core
|
|
| Paket | Version | Zweck |
|
|
|---|---|---|
|
|
| `react` | ^19.0 | UI-Framework |
|
|
| `react-dom` | ^19.0 | DOM-Rendering |
|
|
| `vite` | ^6.x | Build-Tool, Dev-Server, HMR |
|
|
| `tailwindcss` | ^4.x | Utility-first CSS |
|
|
| `react-router` | ^7.x | Client-Side Routing |
|
|
| `@tanstack/react-query` | ^5.x | Server-State, Caching, Mutations |
|
|
|
|
### UI & Interaktion
|
|
| Paket | Zweck |
|
|
|---|---|
|
|
| `react-masonry-css` | Pinterest-artiges Grid-Layout |
|
|
| `framer-motion` | Animationen, Page-Transitions, Swipe-Gesten |
|
|
| `react-hot-toast` | Toast-Benachrichtigungen |
|
|
| `lucide-react` | Icon-Set (sauber, konsistent) |
|
|
| `react-hook-form` + `zod` | Formulare + Validierung |
|
|
|
|
### PWA
|
|
| Paket | Zweck |
|
|
|---|---|
|
|
| `vite-plugin-pwa` | Service Worker Generation, Manifest |
|
|
| `workbox` (via Plugin) | Caching-Strategien, Offline-Support |
|
|
|
|
### Projektstruktur
|
|
|
|
```
|
|
frontend/
|
|
├── public/
|
|
│ ├── icons/ ← PWA Icons (192, 512px)
|
|
│ └── manifest.webmanifest
|
|
├── src/
|
|
│ ├── main.tsx
|
|
│ ├── App.tsx
|
|
│ ├── api/ ← API-Client (fetch-Wrapper)
|
|
│ │ ├── client.ts
|
|
│ │ ├── recipes.ts
|
|
│ │ ├── shopping.ts
|
|
│ │ └── types.ts
|
|
│ ├── components/
|
|
│ │ ├── layout/ ← AppShell, BottomNav, TopBar
|
|
│ │ ├── recipe/ ← RecipeCard, IngredientList, StepList
|
|
│ │ ├── cooking/ ← CookingStep, CookingTimer
|
|
│ │ ├── shopping/ ← ShoppingItem, ShoppingSection
|
|
│ │ ├── forms/ ← RecipeForm, ImageUpload
|
|
│ │ └── ui/ ← Button, Badge, Input, Skeleton
|
|
│ ├── pages/
|
|
│ │ ├── HomePage.tsx
|
|
│ │ ├── SearchPage.tsx
|
|
│ │ ├── RecipePage.tsx
|
|
│ │ ├── CookingModePage.tsx
|
|
│ │ ├── ShoppingPage.tsx
|
|
│ │ ├── CreateRecipePage.tsx
|
|
│ │ └── SettingsPage.tsx
|
|
│ ├── hooks/ ← useRecipes, useTimer, useWakeLock
|
|
│ ├── lib/ ← Hilfsfunktionen, Konstanten
|
|
│ └── styles/
|
|
│ └── globals.css ← Tailwind-Imports, Custom Properties
|
|
├── index.html
|
|
├── tailwind.config.ts
|
|
├── vite.config.ts
|
|
├── tsconfig.json
|
|
└── package.json
|
|
```
|
|
|
|
### PWA-Konfiguration (vite.config.ts)
|
|
|
|
```typescript
|
|
import { VitePWA } from 'vite-plugin-pwa';
|
|
|
|
export default defineConfig({
|
|
plugins: [
|
|
react(),
|
|
VitePWA({
|
|
registerType: 'autoUpdate',
|
|
includeAssets: ['icons/*.png'],
|
|
manifest: {
|
|
name: 'Luna Recipes',
|
|
short_name: 'Luna',
|
|
description: 'Deine persönliche Rezeptsammlung',
|
|
theme_color: '#C4737E',
|
|
background_color: '#FBF8F5',
|
|
display: 'standalone',
|
|
orientation: 'portrait',
|
|
start_url: '/',
|
|
icons: [
|
|
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
|
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
|
{ src: 'icons/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
|
],
|
|
},
|
|
workbox: {
|
|
runtimeCaching: [
|
|
{
|
|
urlPattern: /\/api\/recipes/,
|
|
handler: 'StaleWhileRevalidate',
|
|
options: { cacheName: 'recipes-cache', expiration: { maxEntries: 200 } },
|
|
},
|
|
{
|
|
urlPattern: /\/images\//,
|
|
handler: 'CacheFirst',
|
|
options: { cacheName: 'image-cache', expiration: { maxEntries: 500, maxAgeSeconds: 30 * 24 * 60 * 60 } },
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
```
|
|
|
|
### Tailwind Theme (tailwind.config.ts)
|
|
|
|
```typescript
|
|
export default {
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
primary: { DEFAULT: '#C4737E', light: '#F2D7DB' },
|
|
secondary: '#D4A574',
|
|
cream: '#FBF8F5',
|
|
espresso: '#2D2016',
|
|
'warm-grey': '#7A6E65',
|
|
sage: '#7BAE7F',
|
|
berry: '#C94C4C',
|
|
sand: '#E8E0D8',
|
|
},
|
|
fontFamily: {
|
|
display: ['Playfair Display', 'serif'],
|
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
|
mono: ['JetBrains Mono', 'monospace'],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
```
|
|
|
|
## 3. Backend
|
|
|
|
### Core
|
|
| Paket | Zweck |
|
|
|---|---|
|
|
| `fastify` v5 | HTTP-Framework (schnell, Schema-basiert) |
|
|
| `better-sqlite3` | SQLite-Treiber (synchron, schnell) |
|
|
| `@fastify/multipart` | Bild-Upload |
|
|
| `@fastify/static` | Statische Dateien (Bilder) |
|
|
| `@fastify/cors` | CORS für Frontend |
|
|
| `@fastify/rate-limit` | Rate-Limiting (Bot-API Schutz) |
|
|
| `sharp` | Bild-Verarbeitung (Resize, WebP) |
|
|
| `ulid` | ID-Generierung |
|
|
| `zod` | Request-Validierung |
|
|
|
|
### Projektstruktur
|
|
|
|
```
|
|
backend/
|
|
├── src/
|
|
│ ├── index.ts ← Server-Start
|
|
│ ├── app.ts ← Fastify-App Setup
|
|
│ ├── db/
|
|
│ │ ├── connection.ts ← SQLite-Verbindung
|
|
│ │ ├── migrate.ts ← Schema-Migrationen
|
|
│ │ └── migrations/ ← SQL-Dateien
|
|
│ ├── routes/
|
|
│ │ ├── recipes.ts ← CRUD + Suche
|
|
│ │ ├── categories.ts ← Kategorien
|
|
│ │ ├── shopping.ts ← Einkaufsliste
|
|
│ │ ├── notes.ts ← Notizen
|
|
│ │ ├── images.ts ← Bild-Upload
|
|
│ │ └── bot.ts ← Bot-API (Token-Auth)
|
|
│ ├── services/
|
|
│ │ ├── recipe.service.ts
|
|
│ │ ├── shopping.service.ts
|
|
│ │ └── image.service.ts
|
|
│ ├── schemas/ ← Zod-Schemas
|
|
│ └── lib/ ← Hilfsfunktionen
|
|
├── data/
|
|
│ ├── luna.db ← SQLite-Datenbank
|
|
│ └── images/ ← Hochgeladene Bilder
|
|
├── tsconfig.json
|
|
└── package.json
|
|
```
|
|
|
|
### Authentifizierung
|
|
|
|
Einfaches Token-System (Single-User App):
|
|
|
|
```
|
|
# .env
|
|
BOT_API_TOKEN=geheimer-bot-token-hier
|
|
ADMIN_TOKEN=optionaler-admin-token
|
|
```
|
|
|
|
- **Frontend:** Kein Auth nötig (lokale Nutzung / privat)
|
|
- **Bot-API:** Bearer-Token im Header: `Authorization: Bearer {BOT_API_TOKEN}`
|
|
- **Optional Phase 2:** Session-basierte Auth falls öffentlich gehostet
|
|
|
|
## 4. API-Endpunkte
|
|
|
|
### Rezepte
|
|
|
|
| Methode | Pfad | Beschreibung |
|
|
|---|---|---|
|
|
| `GET` | `/api/recipes` | Alle Rezepte (paginiert, filterbar) |
|
|
| `GET` | `/api/recipes/:slug` | Einzelnes Rezept (komplett mit Zutaten, Steps, Notes) |
|
|
| `POST` | `/api/recipes` | Neues Rezept erstellen |
|
|
| `PUT` | `/api/recipes/:id` | Rezept aktualisieren |
|
|
| `DELETE` | `/api/recipes/:id` | Rezept löschen |
|
|
| `PATCH` | `/api/recipes/:id/favorite` | Favorit togglen |
|
|
| `GET` | `/api/recipes/search` | Volltextsuche |
|
|
|
|
#### Query-Parameter für `GET /api/recipes`
|
|
|
|
| Parameter | Typ | Beschreibung |
|
|
|---|---|---|
|
|
| `category` | string | Filter nach Kategorie-Slug |
|
|
| `favorite` | boolean | Nur Favoriten |
|
|
| `difficulty` | string | easy/medium/hard |
|
|
| `maxTime` | number | Maximale Gesamtzeit in Minuten |
|
|
| `ingredient` | string | Enthält Zutat (Textsuche) |
|
|
| `page` | number | Seite (default: 1) |
|
|
| `limit` | number | Einträge pro Seite (default: 20, max: 50) |
|
|
| `sort` | string | `newest`, `oldest`, `title`, `time` |
|
|
|
|
#### Request-Body `POST /api/recipes`
|
|
|
|
```json
|
|
{
|
|
"title": "Schwarzwälder Kirschtorte",
|
|
"description": "Klassiker der deutschen Backkunst",
|
|
"category_id": "cat_torten",
|
|
"servings": 12,
|
|
"prep_time_min": 45,
|
|
"cook_time_min": 35,
|
|
"difficulty": "medium",
|
|
"source_url": "https://pinterest.com/...",
|
|
"tags": ["schokolade", "kirschen", "sahne"],
|
|
"ingredients": [
|
|
{ "amount": 200, "unit": "g", "name": "Mehl", "group_name": "Teig" },
|
|
{ "amount": 150, "unit": "g", "name": "Zucker", "group_name": "Teig" },
|
|
{ "amount": 500, "unit": "ml", "name": "Sahne", "group_name": "Füllung" }
|
|
],
|
|
"steps": [
|
|
{ "instruction": "Eier trennen und Eiweiß steif schlagen.", "timer_minutes": null },
|
|
{ "instruction": "Teig in Springform füllen und backen.", "timer_minutes": 35, "timer_label": "Backen" }
|
|
]
|
|
}
|
|
```
|
|
|
|
### Kategorien
|
|
|
|
| Methode | Pfad | Beschreibung |
|
|
|---|---|---|
|
|
| `GET` | `/api/categories` | Alle Kategorien |
|
|
| `POST` | `/api/categories` | Neue Kategorie (custom) |
|
|
|
|
### Notizen
|
|
|
|
| Methode | Pfad | Beschreibung |
|
|
|---|---|---|
|
|
| `GET` | `/api/recipes/:id/notes` | Notizen eines Rezepts |
|
|
| `POST` | `/api/recipes/:id/notes` | Notiz hinzufügen |
|
|
| `PUT` | `/api/notes/:id` | Notiz bearbeiten |
|
|
| `DELETE` | `/api/notes/:id` | Notiz löschen |
|
|
|
|
### Einkaufsliste
|
|
|
|
| Methode | Pfad | Beschreibung |
|
|
|---|---|---|
|
|
| `GET` | `/api/shopping` | Aktuelle Einkaufsliste |
|
|
| `POST` | `/api/shopping/from-recipe/:id` | Zutaten eines Rezepts hinzufügen |
|
|
| `POST` | `/api/shopping` | Eigenen Eintrag hinzufügen |
|
|
| `PATCH` | `/api/shopping/:id/check` | Abhaken/Enthaken |
|
|
| `DELETE` | `/api/shopping/:id` | Eintrag entfernen |
|
|
| `DELETE` | `/api/shopping/checked` | Alle abgehakten entfernen |
|
|
|
|
### Bilder
|
|
|
|
| Methode | Pfad | Beschreibung |
|
|
|---|---|---|
|
|
| `POST` | `/api/recipes/:id/image` | Hauptbild hochladen (multipart) |
|
|
| `POST` | `/api/recipes/:id/steps/:n/image` | Schrittbild hochladen |
|
|
|
|
### Bot-API
|
|
|
|
Alle Bot-Endpunkte erfordern `Authorization: Bearer {token}`.
|
|
|
|
| Methode | Pfad | Beschreibung |
|
|
|---|---|---|
|
|
| `POST` | `/api/bot/recipes` | Rezept per Bot erstellen (wie POST /api/recipes) |
|
|
| `POST` | `/api/bot/recipes/:id/image` | Bild per URL importieren |
|
|
| `GET` | `/api/bot/recipes` | Rezepte auflisten (für Bot-Abfragen) |
|
|
|
|
#### Bot-spezifisches Feld
|
|
|
|
```json
|
|
{
|
|
"image_url": "https://example.com/foto.jpg",
|
|
"...normaler recipe body..."
|
|
}
|
|
```
|
|
|
|
Das Backend lädt das Bild herunter, konvertiert zu WebP, erstellt Thumbnail.
|
|
|
|
### Tags
|
|
|
|
| Methode | Pfad | Beschreibung |
|
|
|---|---|---|
|
|
| `GET` | `/api/tags` | Alle Tags (mit Anzahl Rezepte) |
|
|
| `GET` | `/api/tags/:name/recipes` | Rezepte eines Tags |
|
|
|
|
## 5. Deployment
|
|
|
|
### Entwicklung
|
|
|
|
```bash
|
|
# Backend
|
|
cd backend && npm install && npm run dev # Port 3000
|
|
|
|
# Frontend
|
|
cd frontend && npm install && npm run dev # Port 5173 (Proxy → 3000)
|
|
```
|
|
|
|
### Produktion
|
|
|
|
```bash
|
|
# Frontend bauen
|
|
cd frontend && npm run build # → dist/
|
|
|
|
# Backend serviert Frontend-Build als Static Files
|
|
# Alles als ein Prozess:
|
|
cd backend && NODE_ENV=production npm start
|
|
```
|
|
|
|
### Docker (empfohlen)
|
|
|
|
```dockerfile
|
|
FROM node:22-alpine
|
|
|
|
WORKDIR /app
|
|
|
|
# Backend Dependencies
|
|
COPY backend/package*.json backend/
|
|
RUN cd backend && npm ci --production
|
|
|
|
# Frontend Build
|
|
COPY frontend/ frontend/
|
|
RUN cd frontend && npm ci && npm run build
|
|
|
|
# Backend Source
|
|
COPY backend/ backend/
|
|
|
|
# Daten-Verzeichnis
|
|
VOLUME /app/data
|
|
|
|
ENV NODE_ENV=production
|
|
ENV DATABASE_PATH=/app/data/luna.db
|
|
ENV IMAGES_PATH=/app/data/images
|
|
|
|
EXPOSE 3000
|
|
CMD ["node", "backend/dist/index.js"]
|
|
```
|
|
|
|
```yaml
|
|
# docker-compose.yml
|
|
services:
|
|
luna:
|
|
build: .
|
|
ports:
|
|
- "3000:3000"
|
|
volumes:
|
|
- ./data:/app/data
|
|
environment:
|
|
- BOT_API_TOKEN=${BOT_API_TOKEN}
|
|
restart: unless-stopped
|
|
```
|
|
|
|
## 6. Performance-Überlegungen
|
|
|
|
- **SQLite WAL-Mode** → Paralleles Lesen während Schreiben
|
|
- **FTS5** → Schnelle Volltextsuche ohne externen Service
|
|
- **Bild-Thumbnails** → Kleine Bilder für Card-Grid, große nur bei Detail
|
|
- **Stale-While-Revalidate** → Instant UI, Background-Refresh
|
|
- **CacheFirst für Bilder** → Bilder ändern sich selten
|
|
- **Lazy Loading** → Bilder + Seiten on-demand laden
|
|
- **Bundle-Splitting** → Kochmodus als separater Chunk
|
|
|
|
## 7. Offline-Strategie (PWA)
|
|
|
|
| Ressource | Strategie | Cache |
|
|
|---|---|---|
|
|
| App-Shell (HTML/JS/CSS) | Precache | Beim Build |
|
|
| API-Responses | StaleWhileRevalidate | Runtime |
|
|
| Bilder | CacheFirst | Runtime, max 500 |
|
|
| Einkaufsliste | NetworkFirst | Runtime |
|
|
|
|
Die Einkaufsliste bekommt NetworkFirst, damit im Laden immer der aktuellste Stand gezeigt wird, aber offline trotzdem funktioniert.
|