commit c19483ea81bbf1837d3dce119fdd255ca97a4955 Author: clawd Date: Fri Feb 20 09:59:36 2026 +0000 v0.1.0 — Phase 1: Go Backend + SQLite + Seed Data - Wails project setup (Go + React-TS) - SQLite schema (allergens, additives, products, week_plans, plan_entries, special_days) - 14 EU allergens (LMIV 1169/2011) - 24 German food additives - 99 products imported from Excel with allergen/additive mappings - Full Wails bindings (CRUD for products, week plans, entries, special days) - OTA updater stub (version check against HTTPS endpoint) - Pure Go SQLite (no CGO) for easy Windows cross-compilation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad79a4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Build +build/ +speiseplan +speiseplan.exe +speiseplan.db + +# Go +*.exe +*.test + +# Frontend +frontend/node_modules/ +frontend/dist/ + +# Wails generated +frontend/wailsjs/ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2169cc --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# README + +## About + +This is the official Wails React-TS template. + +You can configure the project by editing `wails.json`. More information about the project settings can be found +here: https://wails.io/docs/reference/project-config + +## Live Development + +To run in live development mode, run `wails dev` in the project directory. This will run a Vite development +server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser +and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect +to this in your browser, and you can call your Go code from devtools. + +## Building + +To build a redistributable, production mode package, use `wails build`. diff --git a/app.go b/app.go new file mode 100644 index 0000000..f81e2c9 --- /dev/null +++ b/app.go @@ -0,0 +1,466 @@ +package main + +import ( + "context" + "fmt" +) + +// App struct +type App struct { + ctx context.Context + updater *Updater +} + +// NewApp creates a new App application struct +func NewApp() *App { + return &App{ + updater: NewUpdater(), + } +} + +// startup is called when the app starts. The context is saved +// so we can call the runtime methods +func (a *App) startup(ctx context.Context) { + a.ctx = ctx +} + +// Greet returns a greeting for the given name +func (a *App) Greet(name string) string { + return fmt.Sprintf("Hello %s, It's show time!", name) +} + +// PRODUKTE + +// GetProducts gibt alle Produkte zurück +func (a *App) GetProducts() ([]Product, error) { + query := ` + SELECT p.id, p.name, p.multiline + FROM products p + ORDER BY p.name + ` + + var products []Product + err := db.Select(&products, query) + if err != nil { + return nil, fmt.Errorf("failed to get products: %w", err) + } + + // Allergene und Zusatzstoffe für jedes Produkt laden + for i := range products { + if err := loadProductRelations(&products[i]); err != nil { + return nil, err + } + } + + return products, nil +} + +// GetProduct gibt ein einzelnes Produkt zurück +func (a *App) GetProduct(id int) (*Product, error) { + var product Product + query := "SELECT id, name, multiline FROM products WHERE id = ?" + err := db.Get(&product, query, id) + if err != nil { + return nil, fmt.Errorf("failed to get product: %w", err) + } + + if err := loadProductRelations(&product); err != nil { + return nil, err + } + + return &product, nil +} + +// CreateProduct erstellt ein neues Produkt +func (a *App) CreateProduct(name string, multiline bool, allergenIDs []string, additiveIDs []string) (*Product, error) { + tx, err := db.Beginx() + if err != nil { + return nil, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Produkt einfügen + result, err := tx.Exec("INSERT INTO products (name, multiline) VALUES (?, ?)", name, multiline) + if err != nil { + return nil, fmt.Errorf("failed to insert product: %w", err) + } + + productID, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("failed to get product ID: %w", err) + } + + // Allergene zuordnen + for _, allergenID := range allergenIDs { + _, err := tx.Exec("INSERT INTO product_allergens (product_id, allergen_id) VALUES (?, ?)", productID, allergenID) + if err != nil { + return nil, fmt.Errorf("failed to link allergen: %w", err) + } + } + + // Zusatzstoffe zuordnen + for _, additiveID := range additiveIDs { + _, err := tx.Exec("INSERT INTO product_additives (product_id, additive_id) VALUES (?, ?)", productID, additiveID) + if err != nil { + return nil, fmt.Errorf("failed to link additive: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("failed to commit transaction: %w", err) + } + + return a.GetProduct(int(productID)) +} + +// UpdateProduct aktualisiert ein Produkt +func (a *App) UpdateProduct(id int, name string, multiline bool, allergenIDs []string, additiveIDs []string) (*Product, error) { + tx, err := db.Beginx() + if err != nil { + return nil, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Produkt aktualisieren + _, err = tx.Exec("UPDATE products SET name = ?, multiline = ? WHERE id = ?", name, multiline, id) + if err != nil { + return nil, fmt.Errorf("failed to update product: %w", err) + } + + // Alte Zuordnungen löschen + _, err = tx.Exec("DELETE FROM product_allergens WHERE product_id = ?", id) + if err != nil { + return nil, fmt.Errorf("failed to delete old allergen links: %w", err) + } + + _, err = tx.Exec("DELETE FROM product_additives WHERE product_id = ?", id) + if err != nil { + return nil, fmt.Errorf("failed to delete old additive links: %w", err) + } + + // Neue Allergene zuordnen + for _, allergenID := range allergenIDs { + _, err := tx.Exec("INSERT INTO product_allergens (product_id, allergen_id) VALUES (?, ?)", id, allergenID) + if err != nil { + return nil, fmt.Errorf("failed to link allergen: %w", err) + } + } + + // Neue Zusatzstoffe zuordnen + for _, additiveID := range additiveIDs { + _, err := tx.Exec("INSERT INTO product_additives (product_id, additive_id) VALUES (?, ?)", id, additiveID) + if err != nil { + return nil, fmt.Errorf("failed to link additive: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("failed to commit transaction: %w", err) + } + + return a.GetProduct(id) +} + +// DeleteProduct löscht ein Produkt +func (a *App) DeleteProduct(id int) error { + _, err := db.Exec("DELETE FROM products WHERE id = ?", id) + if err != nil { + return fmt.Errorf("failed to delete product: %w", err) + } + return nil +} + +// ALLERGENE & ZUSATZSTOFFE + +// GetAllergens gibt alle Allergene zurück +func (a *App) GetAllergens() ([]Allergen, error) { + var allergens []Allergen + err := db.Select(&allergens, "SELECT id, name, category FROM allergens ORDER BY id") + if err != nil { + return nil, fmt.Errorf("failed to get allergens: %w", err) + } + return allergens, nil +} + +// GetAdditives gibt alle Zusatzstoffe zurück +func (a *App) GetAdditives() ([]Additive, error) { + var additives []Additive + err := db.Select(&additives, "SELECT id, name FROM additives ORDER BY id") + if err != nil { + return nil, fmt.Errorf("failed to get additives: %w", err) + } + return additives, nil +} + +// WOCHENPLÄNE + +// GetWeekPlan gibt einen Wochenplan zurück +func (a *App) GetWeekPlan(year int, week int) (*WeekPlan, error) { + var plan WeekPlan + query := "SELECT id, year, week, created_at FROM week_plans WHERE year = ? AND week = ?" + err := db.Get(&plan, query, year, week) + if err != nil { + return nil, fmt.Errorf("failed to get week plan: %w", err) + } + + // Einträge laden + entries, err := a.loadPlanEntries(plan.ID) + if err != nil { + return nil, err + } + plan.Entries = entries + + // Sondertage laden + specialDays, err := a.loadSpecialDays(plan.ID) + if err != nil { + return nil, err + } + plan.SpecialDays = specialDays + + return &plan, nil +} + +// CreateWeekPlan erstellt einen neuen Wochenplan +func (a *App) CreateWeekPlan(year int, week int) (*WeekPlan, error) { + _, err := db.Exec("INSERT INTO week_plans (year, week) VALUES (?, ?)", year, week) + if err != nil { + return nil, fmt.Errorf("failed to create week plan: %w", err) + } + + return a.GetWeekPlan(year, week) +} + +// CopyWeekPlan kopiert einen Wochenplan +func (a *App) CopyWeekPlan(sourceYear int, sourceWeek int, targetYear int, targetWeek int) (*WeekPlan, error) { + // Erst Zielplan erstellen + targetPlan, err := a.CreateWeekPlan(targetYear, targetWeek) + if err != nil { + return nil, err + } + + // Quellplan laden + sourcePlan, err := a.GetWeekPlan(sourceYear, sourceWeek) + if err != nil { + return nil, err + } + + tx, err := db.Beginx() + if err != nil { + return nil, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Einträge kopieren + for _, entry := range sourcePlan.Entries { + _, err := tx.Exec(` + INSERT INTO plan_entries (week_plan_id, day, meal, slot, product_id, custom_text, group_label) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, targetPlan.ID, entry.Day, entry.Meal, entry.Slot, entry.ProductID, entry.CustomText, entry.GroupLabel) + if err != nil { + return nil, fmt.Errorf("failed to copy plan entry: %w", err) + } + } + + // Sondertage kopieren + for _, special := range sourcePlan.SpecialDays { + _, err := tx.Exec(` + INSERT INTO special_days (week_plan_id, day, type, label) + VALUES (?, ?, ?, ?) + `, targetPlan.ID, special.Day, special.Type, special.Label) + if err != nil { + return nil, fmt.Errorf("failed to copy special day: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("failed to commit transaction: %w", err) + } + + return a.GetWeekPlan(targetYear, targetWeek) +} + +// PLAN-EINTRÄGE + +// AddPlanEntry fügt einen Planeintrag hinzu +func (a *App) AddPlanEntry(weekPlanID int, day int, meal string, productID *int, customText *string, groupLabel *string) (*PlanEntry, error) { + // Nächste Slot-Nummer ermitteln + var maxSlot int + err := db.Get(&maxSlot, "SELECT COALESCE(MAX(slot), -1) FROM plan_entries WHERE week_plan_id = ? AND day = ? AND meal = ?", weekPlanID, day, meal) + if err != nil { + return nil, fmt.Errorf("failed to get max slot: %w", err) + } + + slot := maxSlot + 1 + + result, err := db.Exec(` + INSERT INTO plan_entries (week_plan_id, day, meal, slot, product_id, custom_text, group_label) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, weekPlanID, day, meal, slot, productID, customText, groupLabel) + if err != nil { + return nil, fmt.Errorf("failed to add plan entry: %w", err) + } + + entryID, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("failed to get entry ID: %w", err) + } + + return a.getPlanEntry(int(entryID)) +} + +// RemovePlanEntry entfernt einen Planeintrag +func (a *App) RemovePlanEntry(id int) error { + _, err := db.Exec("DELETE FROM plan_entries WHERE id = ?", id) + if err != nil { + return fmt.Errorf("failed to remove plan entry: %w", err) + } + return nil +} + +// UpdatePlanEntry aktualisiert einen Planeintrag +func (a *App) UpdatePlanEntry(id int, productID *int, customText *string, groupLabel *string) (*PlanEntry, error) { + _, err := db.Exec(` + UPDATE plan_entries + SET product_id = ?, custom_text = ?, group_label = ? + WHERE id = ? + `, productID, customText, groupLabel, id) + if err != nil { + return nil, fmt.Errorf("failed to update plan entry: %w", err) + } + + return a.getPlanEntry(id) +} + +// SONDERTAGE + +// SetSpecialDay setzt einen Sondertag +func (a *App) SetSpecialDay(weekPlanID int, day int, dtype string, label string) error { + _, err := db.Exec(` + INSERT OR REPLACE INTO special_days (week_plan_id, day, type, label) + VALUES (?, ?, ?, ?) + `, weekPlanID, day, dtype, label) + if err != nil { + return fmt.Errorf("failed to set special day: %w", err) + } + return nil +} + +// RemoveSpecialDay entfernt einen Sondertag +func (a *App) RemoveSpecialDay(weekPlanID int, day int) error { + _, err := db.Exec("DELETE FROM special_days WHERE week_plan_id = ? AND day = ?", weekPlanID, day) + if err != nil { + return fmt.Errorf("failed to remove special day: %w", err) + } + return nil +} + +// OTA UPDATE + +// CheckForUpdate prüft auf verfügbare Updates +func (a *App) CheckForUpdate() (*UpdateInfo, error) { + return a.updater.CheckForUpdate() +} + +// HILFSFUNKTIONEN + +// loadProductRelations lädt Allergene und Zusatzstoffe für ein Produkt +func loadProductRelations(product *Product) error { + // Allergene laden + allergenQuery := ` + SELECT a.id, a.name, a.category + FROM allergens a + JOIN product_allergens pa ON a.id = pa.allergen_id + WHERE pa.product_id = ? + ORDER BY a.id + ` + err := db.Select(&product.Allergens, allergenQuery, product.ID) + if err != nil { + return fmt.Errorf("failed to load allergens for product %d: %w", product.ID, err) + } + + // Zusatzstoffe laden + additiveQuery := ` + SELECT a.id, a.name + FROM additives a + JOIN product_additives pa ON a.id = pa.additive_id + WHERE pa.product_id = ? + ORDER BY a.id + ` + err = db.Select(&product.Additives, additiveQuery, product.ID) + if err != nil { + return fmt.Errorf("failed to load additives for product %d: %w", product.ID, err) + } + + return nil +} + +// loadPlanEntries lädt Einträge für einen Wochenplan +func (a *App) loadPlanEntries(weekPlanID int) ([]PlanEntry, error) { + query := ` + SELECT pe.id, pe.week_plan_id, pe.day, pe.meal, pe.slot, + pe.product_id, pe.custom_text, pe.group_label + FROM plan_entries pe + WHERE pe.week_plan_id = ? + ORDER BY pe.day, pe.meal, pe.slot + ` + + var entries []PlanEntry + err := db.Select(&entries, query, weekPlanID) + if err != nil { + return nil, fmt.Errorf("failed to load plan entries: %w", err) + } + + // Produkte laden + for i := range entries { + if entries[i].ProductID != nil { + product, err := a.GetProduct(*entries[i].ProductID) + if err != nil { + return nil, err + } + entries[i].Product = product + } + } + + return entries, nil +} + +// loadSpecialDays lädt Sondertage für einen Wochenplan +func (a *App) loadSpecialDays(weekPlanID int) ([]SpecialDay, error) { + var specialDays []SpecialDay + query := ` + SELECT id, week_plan_id, day, type, label + FROM special_days + WHERE week_plan_id = ? + ORDER BY day + ` + err := db.Select(&specialDays, query, weekPlanID) + if err != nil { + return nil, fmt.Errorf("failed to load special days: %w", err) + } + return specialDays, nil +} + +// getPlanEntry lädt einen einzelnen Planeintrag +func (a *App) getPlanEntry(id int) (*PlanEntry, error) { + var entry PlanEntry + query := ` + SELECT id, week_plan_id, day, meal, slot, product_id, custom_text, group_label + FROM plan_entries + WHERE id = ? + ` + err := db.Get(&entry, query, id) + if err != nil { + return nil, fmt.Errorf("failed to get plan entry: %w", err) + } + + // Produkt laden wenn vorhanden + if entry.ProductID != nil { + product, err := a.GetProduct(*entry.ProductID) + if err != nil { + return nil, err + } + entry.Product = product + } + + return &entry, nil +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..a313020 --- /dev/null +++ b/db.go @@ -0,0 +1,127 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/jmoiron/sqlx" + _ "modernc.org/sqlite" +) + +var db *sqlx.DB + +// InitDatabase initialisiert die SQLite-Datenbank +func InitDatabase() error { + // DB-Datei im User-Home-Verzeichnis + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + + dbPath := filepath.Join(homeDir, "speiseplan.db") + + // Verbindung zur Datenbank herstellen + database, err := sqlx.Open("sqlite", dbPath) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + + db = database + + // Schema erstellen + if err := createSchema(); err != nil { + return fmt.Errorf("failed to create schema: %w", err) + } + + // Seed-Daten einfügen + if err := SeedDatabase(); err != nil { + return fmt.Errorf("failed to seed database: %w", err) + } + + return nil +} + +// createSchema erstellt das Datenbankschema +func createSchema() error { + schema := ` + -- 14 EU-Allergene (gesetzlich vorgeschrieben, LMIV Verordnung) + CREATE TABLE IF NOT EXISTS allergens ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'allergen' + ); + + -- Zusatzstoffe (deutsche Lebensmittelkennzeichnung) + CREATE TABLE IF NOT EXISTS additives ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL + ); + + -- Produkte + CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + multiline BOOLEAN DEFAULT FALSE + ); + + -- Produkt-Allergen-Zuordnung (n:m) + CREATE TABLE IF NOT EXISTS product_allergens ( + product_id INTEGER REFERENCES products(id) ON DELETE CASCADE, + allergen_id TEXT REFERENCES allergens(id), + PRIMARY KEY (product_id, allergen_id) + ); + + -- Produkt-Zusatzstoff-Zuordnung (n:m) + CREATE TABLE IF NOT EXISTS product_additives ( + product_id INTEGER REFERENCES products(id) ON DELETE CASCADE, + additive_id TEXT REFERENCES additives(id), + PRIMARY KEY (product_id, additive_id) + ); + + -- Wochenpläne + CREATE TABLE IF NOT EXISTS week_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + year INTEGER NOT NULL, + week INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(year, week) + ); + + -- Einträge im Wochenplan + CREATE TABLE IF NOT EXISTS plan_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + week_plan_id INTEGER REFERENCES week_plans(id) ON DELETE CASCADE, + day INTEGER NOT NULL, + meal TEXT NOT NULL, + slot INTEGER NOT NULL DEFAULT 0, + product_id INTEGER REFERENCES products(id), + custom_text TEXT, + group_label TEXT + ); + + -- Sondertage + CREATE TABLE IF NOT EXISTS special_days ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + week_plan_id INTEGER REFERENCES week_plans(id) ON DELETE CASCADE, + day INTEGER NOT NULL, + type TEXT NOT NULL, + label TEXT + );` + + _, err := db.Exec(schema) + return err +} + +// GetDB gibt die Datenbankverbindung zurück +func GetDB() *sqlx.DB { + return db +} + +// CloseDatabase schließt die Datenbankverbindung +func CloseDatabase() error { + if db != nil { + return db.Close() + } + return nil +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c4ec2d2 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + speiseplan + + +
+ + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..eae6bc0 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1566 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@types/react-router-dom": "^5.3.3", + "autoprefixer": "^10.4.24", + "postcss": "^8.5.6", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^7.13.0", + "tailwindcss": "^4.2.0" + }, + "devDependencies": { + "@types/react": "^18.0.17", + "@types/react-dom": "^18.0.6", + "@vitejs/plugin-react": "^2.0.1", + "typescript": "^4.6.4", + "vite": "^3.0.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", + "integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", + "integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-2.2.0.tgz", + "integrity": "sha512-FFpefhvExd1toVRlokZgxgy2JtnBOdp4ZDsq7ldCWaqGSGn9UhWMAVm/1lxPL14JfNS5yGz+s9yFrQY6shoStA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.19.6", + "@babel/plugin-transform-react-jsx": "^7.19.0", + "@babel/plugin-transform-react-jsx-development": "^7.18.6", + "@babel/plugin-transform-react-jsx-self": "^7.18.6", + "@babel/plugin-transform-react-jsx-source": "^7.19.6", + "magic-string": "^0.26.7", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^3.0.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz", + "integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.15.18", + "@esbuild/linux-loong64": "0.15.18", + "esbuild-android-64": "0.15.18", + "esbuild-android-arm64": "0.15.18", + "esbuild-darwin-64": "0.15.18", + "esbuild-darwin-arm64": "0.15.18", + "esbuild-freebsd-64": "0.15.18", + "esbuild-freebsd-arm64": "0.15.18", + "esbuild-linux-32": "0.15.18", + "esbuild-linux-64": "0.15.18", + "esbuild-linux-arm": "0.15.18", + "esbuild-linux-arm64": "0.15.18", + "esbuild-linux-mips64le": "0.15.18", + "esbuild-linux-ppc64le": "0.15.18", + "esbuild-linux-riscv64": "0.15.18", + "esbuild-linux-s390x": "0.15.18", + "esbuild-netbsd-64": "0.15.18", + "esbuild-openbsd-64": "0.15.18", + "esbuild-sunos-64": "0.15.18", + "esbuild-windows-32": "0.15.18", + "esbuild-windows-64": "0.15.18", + "esbuild-windows-arm64": "0.15.18" + } + }, + "node_modules/esbuild-android-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz", + "integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-android-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz", + "integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz", + "integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz", + "integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz", + "integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz", + "integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz", + "integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz", + "integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz", + "integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz", + "integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-mips64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz", + "integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-ppc64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz", + "integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-riscv64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz", + "integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-s390x": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz", + "integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-netbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz", + "integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-openbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz", + "integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-sunos-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz", + "integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz", + "integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz", + "integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz", + "integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.7.tgz", + "integrity": "sha512-hX9XH3ziStPoPhJxLq1syWuZMxbDvGNbVchfrdCtanC7D13888bMFow61x8axrx+GfHLtVeAx2kxL7tTGRl+Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", + "integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz", + "integrity": "sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.15.9", + "postcss": "^8.4.18", + "resolve": "^1.22.1", + "rollup": "^2.79.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6c23523 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@types/react-router-dom": "^5.3.3", + "autoprefixer": "^10.4.24", + "postcss": "^8.5.6", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^7.13.0", + "tailwindcss": "^4.2.0" + }, + "devDependencies": { + "@types/react": "^18.0.17", + "@types/react-dom": "^18.0.6", + "@vitejs/plugin-react": "^2.0.1", + "typescript": "^4.6.4", + "vite": "^3.0.7" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..f949d9c --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,59 @@ +#app { + height: 100vh; + text-align: center; +} + +#logo { + display: block; + width: 50%; + height: 50%; + margin: auto; + padding: 10% 0 0; + background-position: center; + background-repeat: no-repeat; + background-size: 100% 100%; + background-origin: content-box; +} + +.result { + height: 20px; + line-height: 20px; + margin: 1.5rem auto; +} + +.input-box .btn { + width: 60px; + height: 30px; + line-height: 30px; + border-radius: 3px; + border: none; + margin: 0 0 0 20px; + padding: 0 8px; + cursor: pointer; +} + +.input-box .btn:hover { + background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%); + color: #333333; +} + +.input-box .input { + border: none; + border-radius: 3px; + outline: none; + height: 30px; + line-height: 30px; + padding: 0 10px; + background-color: rgba(240, 240, 240, 1); + -webkit-font-smoothing: antialiased; +} + +.input-box .input:hover { + border: none; + background-color: rgba(255, 255, 255, 1); +} + +.input-box .input:focus { + border: none; + background-color: rgba(255, 255, 255, 1); +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..a6e56f9 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,28 @@ +import {useState} from 'react'; +import logo from './assets/images/logo-universal.png'; +import './App.css'; +import {Greet} from "../wailsjs/go/main/App"; + +function App() { + const [resultText, setResultText] = useState("Please enter your name below 👇"); + const [name, setName] = useState(''); + const updateName = (e: any) => setName(e.target.value); + const updateResultText = (result: string) => setResultText(result); + + function greet() { + Greet(name).then(updateResultText); + } + + return ( +
+ +
{resultText}
+
+ + +
+
+ ) +} + +export default App diff --git a/frontend/src/assets/fonts/OFL.txt b/frontend/src/assets/fonts/OFL.txt new file mode 100644 index 0000000..9cac04c --- /dev/null +++ b/frontend/src/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com), + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 b/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 new file mode 100644 index 0000000..2f9cc59 Binary files /dev/null and b/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 differ diff --git a/frontend/src/assets/images/logo-universal.png b/frontend/src/assets/images/logo-universal.png new file mode 100644 index 0000000..99ac71f Binary files /dev/null and b/frontend/src/assets/images/logo-universal.png differ diff --git a/frontend/src/components/AdditivePicker.tsx b/frontend/src/components/AdditivePicker.tsx new file mode 100644 index 0000000..f5b66fa --- /dev/null +++ b/frontend/src/components/AdditivePicker.tsx @@ -0,0 +1,129 @@ +import { Additive } from '../types'; + +interface AdditivePickerProps { + additives: Additive[]; + selectedIds: string[]; + onChange: (selectedIds: string[]) => void; + className?: string; +} + +// Deutsche Namen für häufige Zusatzstoffe (E-Nummern) +const ADDITIVE_NAMES: Record = { + '1': 'Farbstoff', + '2': 'Konservierungsstoff', + '3': 'Antioxidationsmittel', + '4': 'Geschmacksverstärker', + '5': 'Geschwefelt', + '6': 'Geschwärzt', + '7': 'Gewachst', + '8': 'Phosphat', + '9': 'Süßungsmittel', + '10': 'Phenylalaninquelle', + '11': 'Koffeinhaltig', + '12': 'Chininhaltig', + '13': 'Alkoholhaltig', + '14': 'Nitritpökelsalz', + '15': 'Milchsäure', + '16': 'Citronensäure', + '17': 'Ascorbinsäure', + '18': 'Tocopherol', + '19': 'Lecithin', + '20': 'Johannisbrotkernmehl', + '21': 'Guarkernmehl', + '22': 'Xanthan', + '23': 'Carrageen', + '24': 'Agar' +}; + +export function AdditivePicker({ additives, selectedIds, onChange, className = '' }: AdditivePickerProps) { + const handleToggle = (additiveId: string) => { + if (selectedIds.includes(additiveId)) { + onChange(selectedIds.filter(id => id !== additiveId)); + } else { + onChange([...selectedIds, additiveId]); + } + }; + + // Sortierte Zusatzstoffe + const sortedAdditives = [...additives].sort((a, b) => { + // Numerisch sortieren falls möglich, sonst alphabetisch + const aNum = parseInt(a.id); + const bNum = parseInt(b.id); + if (!isNaN(aNum) && !isNaN(bNum)) { + return aNum - bNum; + } + return a.id.localeCompare(b.id); + }); + + return ( +
+ + Zusatzstoffe auswählen + + +
+ {sortedAdditives.map(additive => { + const isSelected = selectedIds.includes(additive.id); + const displayName = ADDITIVE_NAMES[additive.id] || additive.name; + + return ( + + ); + })} +
+ + {selectedIds.length > 0 && ( +
+

+ Ausgewählte Zusatzstoffe:{' '} + + {selectedIds.sort((a, b) => { + const aNum = parseInt(a); + const bNum = parseInt(b); + if (!isNaN(aNum) && !isNaN(bNum)) { + return aNum - bNum; + } + return a.localeCompare(b); + }).join(', ')} + +

+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/AllergenBadge.tsx b/frontend/src/components/AllergenBadge.tsx new file mode 100644 index 0000000..3026ed5 --- /dev/null +++ b/frontend/src/components/AllergenBadge.tsx @@ -0,0 +1,79 @@ +import { Allergen } from '../types'; + +interface AllergenBadgeProps { + allergen: Allergen; + size?: 'sm' | 'md'; + className?: string; +} + +// Farbkodierung für Allergene (a-n) +const ALLERGEN_COLORS: Record = { + 'a': 'bg-red-500', // Glutenhaltige Getreide + 'b': 'bg-orange-500', // Krebstiere + 'c': 'bg-yellow-500', // Eier + 'd': 'bg-green-500', // Fisch + 'e': 'bg-blue-500', // Erdnüsse + 'f': 'bg-indigo-500', // Soja + 'g': 'bg-purple-500', // Milch/Laktose + 'h': 'bg-pink-500', // Schalenfrüchte + 'i': 'bg-red-400', // Sellerie + 'j': 'bg-orange-400', // Senf + 'k': 'bg-yellow-400', // Sesam + 'l': 'bg-green-400', // Schwefeldioxid + 'm': 'bg-blue-400', // Lupinen + 'n': 'bg-indigo-400', // Weichtiere +}; + +export function AllergenBadge({ allergen, size = 'md', className = '' }: AllergenBadgeProps) { + const colorClass = ALLERGEN_COLORS[allergen.id] || 'bg-gray-500'; + const sizeClass = size === 'sm' + ? 'text-xs px-1.5 py-0.5' + : 'text-xs px-2 py-1'; + + return ( + + {allergen.id} + + ); +} + +interface AllergenListProps { + allergens: Allergen[]; + size?: 'sm' | 'md'; + maxVisible?: number; + className?: string; +} + +export function AllergenList({ allergens, size = 'md', maxVisible, className = '' }: AllergenListProps) { + if (!allergens?.length) return null; + + const visible = maxVisible ? allergens.slice(0, maxVisible) : allergens; + const remaining = maxVisible && allergens.length > maxVisible + ? allergens.length - maxVisible + : 0; + + return ( +
+ {visible.map(allergen => ( + + ))} + {remaining > 0 && ( + + +{remaining} + + )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/AllergenPicker.tsx b/frontend/src/components/AllergenPicker.tsx new file mode 100644 index 0000000..7c8df66 --- /dev/null +++ b/frontend/src/components/AllergenPicker.tsx @@ -0,0 +1,106 @@ +import { Allergen } from '../types'; + +interface AllergenPickerProps { + allergens: Allergen[]; + selectedIds: string[]; + onChange: (selectedIds: string[]) => void; + className?: string; +} + +// Deutsche Namen für Allergene (nach EU-Verordnung) +const ALLERGEN_NAMES: Record = { + 'a': 'Glutenhaltige Getreide', + 'b': 'Krebstiere', + 'c': 'Eier', + 'd': 'Fisch', + 'e': 'Erdnüsse', + 'f': 'Soja', + 'g': 'Milch/Laktose', + 'h': 'Schalenfrüchte', + 'i': 'Sellerie', + 'j': 'Senf', + 'k': 'Sesam', + 'l': 'Schwefeldioxid', + 'm': 'Lupinen', + 'n': 'Weichtiere' +}; + +export function AllergenPicker({ allergens, selectedIds, onChange, className = '' }: AllergenPickerProps) { + const handleToggle = (allergenId: string) => { + if (selectedIds.includes(allergenId)) { + onChange(selectedIds.filter(id => id !== allergenId)); + } else { + onChange([...selectedIds, allergenId]); + } + }; + + // Sortierte Allergene (a-n) + const sortedAllergens = [...allergens].sort((a, b) => a.id.localeCompare(b.id)); + + return ( +
+ + Allergene auswählen + + +
+ {sortedAllergens.map(allergen => { + const isSelected = selectedIds.includes(allergen.id); + const displayName = ALLERGEN_NAMES[allergen.id] || allergen.name; + + return ( + + ); + })} +
+ + {selectedIds.length > 0 && ( +
+

+ Ausgewählte Allergene:{' '} + {selectedIds.sort().join(', ')} +

+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/ProductForm.tsx b/frontend/src/components/ProductForm.tsx new file mode 100644 index 0000000..9b6274a --- /dev/null +++ b/frontend/src/components/ProductForm.tsx @@ -0,0 +1,244 @@ +import { useState, useEffect } from 'react'; +import { Product, ProductFormData, Allergen, Additive } from '../types'; +import { AllergenPicker } from './AllergenPicker'; +import { AdditivePicker } from './AdditivePicker'; + +interface ProductFormProps { + product?: Product; // Für Bearbeitung + allergens: Allergen[]; + additives: Additive[]; + onSubmit: (data: ProductFormData) => Promise; + onCancel: () => void; + loading?: boolean; +} + +export function ProductForm({ + product, + allergens, + additives, + onSubmit, + onCancel, + loading = false +}: ProductFormProps) { + const [formData, setFormData] = useState({ + name: '', + multiline: false, + allergenIds: [], + additiveIds: [] + }); + + const [errors, setErrors] = useState>({}); + + // Initialize form with product data for editing + useEffect(() => { + if (product) { + setFormData({ + name: product.name, + multiline: product.multiline, + allergenIds: product.allergens?.map(a => a.id) || [], + additiveIds: product.additives?.map(a => a.id) || [] + }); + } + }, [product]); + + // Validation + const validate = (): boolean => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = 'Produktname ist erforderlich'; + } else if (formData.name.length > 100) { + newErrors.name = 'Produktname darf maximal 100 Zeichen lang sein'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // Handle form submission + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validate()) return; + + try { + await onSubmit(formData); + } catch (error) { + // Error handling is done in parent component + console.error('Fehler beim Speichern des Produkts:', error); + } + }; + + // Reset form + const handleReset = () => { + setFormData({ + name: '', + multiline: false, + allergenIds: [], + additiveIds: [] + }); + setErrors({}); + }; + + const isEditing = !!product; + + return ( +
+
+
+ {/* Header */} +
+

+ {isEditing ? 'Produkt bearbeiten' : 'Neues Produkt'} +

+ +
+ + {/* Form Content */} +
+ {/* Product Name */} +
+ + setFormData(prev => ({ ...prev, name: e.target.value }))} + className={`input ${errors.name ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`} + placeholder="z.B. Vollkornbrot mit Käse" + maxLength={100} + aria-describedby={errors.name ? 'name-error' : undefined} + required + /> + {errors.name && ( + + )} +

+ {formData.name.length}/100 Zeichen +

+
+ + {/* Multiline Option */} +
+ +

+ Aktivieren, wenn das Produkt in mehreren Zeilen angezeigt werden soll +

+
+ + {/* Allergens */} + setFormData(prev => ({ ...prev, allergenIds: ids }))} + /> + + {/* Additives */} + setFormData(prev => ({ ...prev, additiveIds: ids }))} + /> + + {/* Preview */} + {formData.name.trim() && ( +
+

Vorschau

+
+
{formData.name}
+
+ {formData.allergenIds.length > 0 && ( +
+ {formData.allergenIds.sort().map(id => ( + + {id} + + ))} +
+ )} + {formData.additiveIds.length > 0 && ( + + Zusatzstoffe: {formData.additiveIds.sort((a, b) => { + const aNum = parseInt(a); + const bNum = parseInt(b); + if (!isNaN(aNum) && !isNaN(bNum)) return aNum - bNum; + return a.localeCompare(b); + }).join(', ')} + + )} +
+
+
+ )} +
+ + {/* Footer */} +
+
+ + + {!isEditing && ( + + )} +
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/ProductList.tsx b/frontend/src/components/ProductList.tsx new file mode 100644 index 0000000..a8f99db --- /dev/null +++ b/frontend/src/components/ProductList.tsx @@ -0,0 +1,293 @@ +import { useState } from 'react'; +import { Product, Allergen, Additive } from '../types'; +import { AllergenList } from './AllergenBadge'; +import { ProductForm } from './ProductForm'; + +interface ProductListProps { + products: Product[]; + allergens: Allergen[]; + additives: Additive[]; + onCreateProduct: (data: any) => Promise; + onUpdateProduct: (id: number, data: any) => Promise; + onDeleteProduct: (id: number) => Promise; + loading?: boolean; +} + +export function ProductList({ + products, + allergens, + additives, + onCreateProduct, + onUpdateProduct, + onDeleteProduct, + loading = false +}: ProductListProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [showForm, setShowForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [deletingProductId, setDeletingProductId] = useState(null); + + // Filter products based on search + const filteredProducts = products.filter(product => + product.name.toLowerCase().includes(searchQuery.toLowerCase()) || + product.allergens?.some(a => + a.name.toLowerCase().includes(searchQuery.toLowerCase()) || + a.id.toLowerCase().includes(searchQuery.toLowerCase()) + ) || + product.additives?.some(a => + a.name.toLowerCase().includes(searchQuery.toLowerCase()) || + a.id.toLowerCase().includes(searchQuery.toLowerCase()) + ) + ); + + // Handle create product + const handleCreateProduct = async (data: any) => { + try { + await onCreateProduct(data); + setShowForm(false); + } catch (error) { + console.error('Fehler beim Erstellen des Produkts:', error); + } + }; + + // Handle update product + const handleUpdateProduct = async (data: any) => { + if (!editingProduct) return; + + try { + await onUpdateProduct(editingProduct.id, data); + setEditingProduct(null); + } catch (error) { + console.error('Fehler beim Bearbeiten des Produkts:', error); + } + }; + + // Handle delete product with confirmation + const handleDeleteProduct = async (product: Product) => { + if (deletingProductId === product.id) { + // Second click - confirm deletion + try { + await onDeleteProduct(product.id); + setDeletingProductId(null); + } catch (error) { + console.error('Fehler beim Löschen des Produkts:', error); + setDeletingProductId(null); + } + } else { + // First click - show confirmation + setDeletingProductId(product.id); + + // Auto-cancel after 3 seconds + setTimeout(() => { + setDeletingProductId(prev => prev === product.id ? null : prev); + }, 3000); + } + }; + + const handleEditProduct = (product: Product) => { + setEditingProduct(product); + }; + + const handleCancelEdit = () => { + setEditingProduct(null); + setShowForm(false); + }; + + return ( +
+ {/* Header */} +
+

+ Produktverwaltung +

+ + +
+ + {/* Search */} +
+ +
+
+ + + +
+ setSearchQuery(e.target.value)} + placeholder="Produkte suchen..." + className="input pl-10" + /> +
+ + {searchQuery && ( +

+ {filteredProducts.length} von {products.length} Produkten +

+ )} +
+ + {/* Products Table */} +
+ {filteredProducts.length === 0 ? ( +
+ {products.length === 0 ? ( +
+ + + +

Keine Produkte

+

+ Erstellen Sie Ihr erstes Produkt, um zu beginnen. +

+
+ ) : ( +
+

Keine Suchergebnisse

+

+ Versuchen Sie es mit anderen Suchbegriffen. +

+
+ )} +
+ ) : ( +
+ + + + + + + + + + + + {filteredProducts.map((product) => ( + + + + + + + + ))} + +
+ Produkt + + Allergene + + Zusatzstoffe + + Typ + + Aktionen +
+
+ {product.name} +
+
+ ID: {product.id} +
+
+ + + {product.additives?.length > 0 ? ( + + {product.additives.map(a => a.id).sort().join(', ')} + + ) : ( + - + )} + + + {product.multiline ? 'Mehrzeilig' : 'Einzeilig'} + + + + + +
+
+ )} +
+ + {/* Statistics */} +
+
+
{products.length}
+
Produkte gesamt
+
+ +
+
+ {products.filter(p => (p.allergens?.length || 0) > 0).length} +
+
Mit Allergenen
+
+ +
+
+ {products.filter(p => (p.additives?.length || 0) > 0).length} +
+
Mit Zusatzstoffen
+
+
+ + {/* Product Form Modal */} + {(showForm || editingProduct) && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/ProductSearch.tsx b/frontend/src/components/ProductSearch.tsx new file mode 100644 index 0000000..21980da --- /dev/null +++ b/frontend/src/components/ProductSearch.tsx @@ -0,0 +1,243 @@ +import { useState, useRef, useEffect } from 'react'; +import { Product } from '../types'; +import { AllergenList } from './AllergenBadge'; + +interface ProductSearchProps { + products: Product[]; + onSelect: (product: Product) => void; + onCustomText?: (text: string) => void; + placeholder?: string; + className?: string; + allowCustom?: boolean; +} + +export function ProductSearch({ + products, + onSelect, + onCustomText, + placeholder = 'Produkt suchen oder eingeben...', + className = '', + allowCustom = true +}: ProductSearchProps) { + const [query, setQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Filter products based on search query + const filteredProducts = query.trim() + ? products.filter(product => + product.name.toLowerCase().includes(query.toLowerCase()) + ).slice(0, 10) // Limit to 10 results for performance + : []; + + // Handle product selection + const handleSelect = (product: Product) => { + setQuery(product.name); + setIsOpen(false); + setSelectedIndex(-1); + onSelect(product); + }; + + // Handle custom text entry + const handleCustomEntry = () => { + if (query.trim() && allowCustom && onCustomText) { + onCustomText(query.trim()); + setQuery(''); + setIsOpen(false); + setSelectedIndex(-1); + } + }; + + // Keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) { + if (e.key === 'ArrowDown' || e.key === 'Enter') { + setIsOpen(true); + e.preventDefault(); + } + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex(prev => + prev < filteredProducts.length - (allowCustom && query.trim() ? 0 : 1) + ? prev + 1 + : prev + ); + break; + + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex(prev => prev > -1 ? prev - 1 : prev); + break; + + case 'Enter': + e.preventDefault(); + if (selectedIndex >= 0 && selectedIndex < filteredProducts.length) { + handleSelect(filteredProducts[selectedIndex]); + } else if (selectedIndex === filteredProducts.length && allowCustom && query.trim()) { + handleCustomEntry(); + } + break; + + case 'Escape': + setIsOpen(false); + setSelectedIndex(-1); + inputRef.current?.blur(); + break; + } + }; + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (inputRef.current && !inputRef.current.contains(event.target as Node)) { + setIsOpen(false); + setSelectedIndex(-1); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Scroll selected item into view + useEffect(() => { + if (selectedIndex >= 0 && listRef.current) { + const selectedElement = listRef.current.children[selectedIndex] as HTMLElement; + selectedElement?.scrollIntoView({ block: 'nearest' }); + } + }, [selectedIndex]); + + const showResults = isOpen && (filteredProducts.length > 0 || (allowCustom && query.trim())); + + return ( +
+ { + setQuery(e.target.value); + setIsOpen(true); + setSelectedIndex(-1); + }} + onFocus={() => setIsOpen(true)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="input" + autoComplete="off" + aria-autocomplete="list" + aria-haspopup="listbox" + aria-label="Produktsuche" + /> + + {showResults && ( +
    + {filteredProducts.map((product, index) => ( +
  • handleSelect(product)} + > +
    + {product.name} + {product.allergens?.length > 0 && ( + + )} +
    + + {product.additives?.length > 0 && ( +
    + Zusatzstoffe: {product.additives.map(a => a.id).join(', ')} +
    + )} +
  • + ))} + + {allowCustom && query.trim() && ( +
  • +
    + ✏️ + Als Freitext eingeben: "{query}" +
    +
  • + )} + + {filteredProducts.length === 0 && (!allowCustom || !query.trim()) && ( +
  • + Keine Produkte gefunden +
  • + )} +
+ )} +
+ ); +} + +// Vereinfachte Version für reine Anzeige +interface ProductDisplayProps { + product: Product; + onRemove?: () => void; + className?: string; +} + +export function ProductDisplay({ product, onRemove, className = '' }: ProductDisplayProps) { + return ( +
+
+
{product.name}
+ +
+ {product.allergens?.length > 0 && ( + + )} + + {product.additives?.length > 0 && ( + + Zusatzstoffe: {product.additives.map(a => a.id).join(', ')} + + )} +
+
+ + {onRemove && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/hooks/useProducts.ts b/frontend/src/hooks/useProducts.ts new file mode 100644 index 0000000..bdf1206 --- /dev/null +++ b/frontend/src/hooks/useProducts.ts @@ -0,0 +1,234 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Product, Allergen, Additive, ProductFormData } from '../types'; + +// Import der Wails-Funktionen (werden zur Laufzeit verfügbar sein) +// @ts-ignore - Wails-Bindings werden zur Laufzeit generiert +import { GetProducts, GetProduct, CreateProduct, UpdateProduct, DeleteProduct, GetAllergens, GetAdditives } from '../../wailsjs/go/main/App'; + +export function useProducts() { + const [products, setProducts] = useState([]); + const [allergens, setAllergens] = useState([]); + const [additives, setAdditives] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Alle Produkte laden + const loadProducts = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const productList = await GetProducts(); + setProducts(productList); + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Laden der Produkte'); + } finally { + setLoading(false); + } + }, []); + + // Stammdaten laden (Allergene und Zusatzstoffe) + const loadMasterData = useCallback(async () => { + try { + const [allergenList, additiveList] = await Promise.all([ + GetAllergens(), + GetAdditives() + ]); + + setAllergens(allergenList); + setAdditives(additiveList); + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Laden der Stammdaten'); + } + }, []); + + // Einzelnes Produkt laden + const getProduct = async (id: number): Promise => { + try { + const product = await GetProduct(id); + return product; + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Laden des Produkts'); + return null; + } + }; + + // Neues Produkt erstellen + const createProduct = async (data: ProductFormData): Promise => { + setLoading(true); + setError(null); + + try { + const newProduct = await CreateProduct( + data.name, + data.multiline, + data.allergenIds, + data.additiveIds + ); + + setProducts(prev => [...prev, newProduct]); + return newProduct; + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Erstellen des Produkts'); + return null; + } finally { + setLoading(false); + } + }; + + // Produkt bearbeiten + const updateProduct = async (id: number, data: ProductFormData): Promise => { + setLoading(true); + setError(null); + + try { + const updatedProduct = await UpdateProduct( + id, + data.name, + data.multiline, + data.allergenIds, + data.additiveIds + ); + + setProducts(prev => prev.map(p => p.id === id ? updatedProduct : p)); + return updatedProduct; + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Bearbeiten des Produkts'); + return null; + } finally { + setLoading(false); + } + }; + + // Produkt löschen + const deleteProduct = async (id: number): Promise => { + setLoading(true); + setError(null); + + try { + await DeleteProduct(id); + setProducts(prev => prev.filter(p => p.id !== id)); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Löschen des Produkts'); + return false; + } finally { + setLoading(false); + } + }; + + // Produkte durchsuchen + const searchProducts = (query: string): Product[] => { + if (!query.trim()) return products; + + const searchTerm = query.toLowerCase(); + return products.filter(product => + product.name.toLowerCase().includes(searchTerm) || + product.allergens.some(a => a.name.toLowerCase().includes(searchTerm)) || + product.additives.some(a => a.name.toLowerCase().includes(searchTerm)) + ); + }; + + // Produkte nach Allergenen filtern + const filterByAllergen = (allergenId: string): Product[] => { + return products.filter(product => + product.allergens.some(a => a.id === allergenId) + ); + }; + + // Produkte nach Zusatzstoffen filtern + const filterByAdditive = (additiveId: string): Product[] => { + return products.filter(product => + product.additives.some(a => a.id === additiveId) + ); + }; + + // Allergen nach ID suchen + const getAllergenById = (id: string): Allergen | undefined => { + return allergens.find(a => a.id === id); + }; + + // Zusatzstoff nach ID suchen + const getAdditiveById = (id: string): Additive | undefined => { + return additives.find(a => a.id === id); + }; + + // Prüfen ob Produkt Allergene enthält + const hasAllergens = (product: Product): boolean => { + return product.allergens && product.allergens.length > 0; + }; + + // Prüfen ob Produkt Zusatzstoffe enthält + const hasAdditives = (product: Product): boolean => { + return product.additives && product.additives.length > 0; + }; + + // Initial laden + useEffect(() => { + loadProducts(); + loadMasterData(); + }, [loadProducts, loadMasterData]); + + return { + // State + products, + allergens, + additives, + loading, + error, + + // Aktionen + loadProducts, + loadMasterData, + getProduct, + createProduct, + updateProduct, + deleteProduct, + + // Suche/Filter + searchProducts, + filterByAllergen, + filterByAdditive, + + // Helper + getAllergenById, + getAdditiveById, + hasAllergens, + hasAdditives, + + // Clear error + clearError: () => setError(null) + }; +} + +// Separater Hook für Autocomplete/Search +export function useProductSearch(initialQuery = '') { + const [query, setQuery] = useState(initialQuery); + const [selectedProductId, setSelectedProductId] = useState(null); + const { products, searchProducts } = useProducts(); + + const results = searchProducts(query); + const selectedProduct = selectedProductId + ? products.find(p => p.id === selectedProductId) + : null; + + const selectProduct = (product: Product) => { + setSelectedProductId(product.id); + setQuery(product.name); + }; + + const clearSelection = () => { + setSelectedProductId(null); + setQuery(''); + }; + + return { + query, + setQuery, + results, + selectedProduct, + selectProduct, + clearSelection, + hasSelection: selectedProductId !== null + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useWeekPlan.ts b/frontend/src/hooks/useWeekPlan.ts new file mode 100644 index 0000000..b3ae8c5 --- /dev/null +++ b/frontend/src/hooks/useWeekPlan.ts @@ -0,0 +1,224 @@ +import { useState, useEffect, useCallback } from 'react'; +import { WeekPlan, PlanEntry, SpecialDay, MealType, WeekDay, GroupLabel } from '../types'; + +// Import der Wails-Funktionen (werden zur Laufzeit verfügbar sein) +// @ts-ignore - Wails-Bindings werden zur Laufzeit generiert +import { GetWeekPlan, CreateWeekPlan, CopyWeekPlan, AddPlanEntry, RemovePlanEntry, UpdatePlanEntry, SetSpecialDay, RemoveSpecialDay } from '../../wailsjs/go/main/App'; + +export function useWeekPlan(year: number, week: number) { + const [weekPlan, setWeekPlan] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Wochenplan laden + const loadWeekPlan = useCallback(async () => { + if (!year || !week) return; + + setLoading(true); + setError(null); + + try { + const plan = await GetWeekPlan(year, week); + setWeekPlan(plan); + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Laden des Wochenplans'); + setWeekPlan(null); + } finally { + setLoading(false); + } + }, [year, week]); + + // Neuen Wochenplan erstellen + const createWeekPlan = async (): Promise => { + setLoading(true); + setError(null); + + try { + const newPlan = await CreateWeekPlan(year, week); + setWeekPlan(newPlan); + return newPlan; + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Erstellen des Wochenplans'); + return null; + } finally { + setLoading(false); + } + }; + + // Wochenplan kopieren + const copyWeekPlan = async (srcYear: number, srcWeek: number): Promise => { + setLoading(true); + setError(null); + + try { + const copiedPlan = await CopyWeekPlan(srcYear, srcWeek, year, week); + setWeekPlan(copiedPlan); + return copiedPlan; + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Kopieren des Wochenplans'); + return null; + } finally { + setLoading(false); + } + }; + + // Eintrag hinzufügen + const addEntry = async ( + day: WeekDay, + meal: MealType, + productId?: number, + customText?: string, + groupLabel?: GroupLabel + ): Promise => { + if (!weekPlan) return null; + + try { + const newEntry = await AddPlanEntry(weekPlan.id, day, meal, productId, customText, groupLabel); + + // State aktualisieren + setWeekPlan(prev => prev ? { + ...prev, + entries: [...prev.entries, newEntry] + } : null); + + return newEntry; + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Hinzufügen des Eintrags'); + return null; + } + }; + + // Eintrag entfernen + const removeEntry = async (entryId: number): Promise => { + try { + await RemovePlanEntry(entryId); + + // State aktualisieren + setWeekPlan(prev => prev ? { + ...prev, + entries: prev.entries.filter(e => e.id !== entryId) + } : null); + + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Entfernen des Eintrags'); + return false; + } + }; + + // Eintrag bearbeiten + const updateEntry = async ( + entryId: number, + day: WeekDay, + meal: MealType, + slot: number, + productId?: number, + customText?: string, + groupLabel?: GroupLabel + ): Promise => { + try { + const updatedEntry = await UpdatePlanEntry(entryId, day, meal, slot, productId, customText, groupLabel); + + // State aktualisieren + setWeekPlan(prev => prev ? { + ...prev, + entries: prev.entries.map(e => e.id === entryId ? updatedEntry : e) + } : null); + + return updatedEntry; + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Bearbeiten des Eintrags'); + return null; + } + }; + + // Sondertag setzen + const setSpecialDay = async (day: WeekDay, type: string, label?: string): Promise => { + if (!weekPlan) return false; + + try { + await SetSpecialDay(weekPlan.id, day, type, label); + + // State aktualisieren + const newSpecialDay: SpecialDay = { + id: Date.now(), // Temporäre ID - wird vom Backend überschrieben + week_plan_id: weekPlan.id, + day, + type, + label + }; + + setWeekPlan(prev => prev ? { + ...prev, + special_days: [...prev.special_days.filter(s => s.day !== day), newSpecialDay] + } : null); + + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Setzen des Sondertags'); + return false; + } + }; + + // Sondertag entfernen + const removeSpecialDay = async (day: WeekDay): Promise => { + if (!weekPlan) return false; + + try { + await RemoveSpecialDay(weekPlan.id, day); + + // State aktualisieren + setWeekPlan(prev => prev ? { + ...prev, + special_days: prev.special_days.filter(s => s.day !== day) + } : null); + + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Entfernen des Sondertags'); + return false; + } + }; + + // Helper-Funktionen für UI + const getEntriesForDay = (day: WeekDay, meal: MealType): PlanEntry[] => { + if (!weekPlan) return []; + return weekPlan.entries + .filter(e => e.day === day && e.meal === meal) + .sort((a, b) => a.slot - b.slot); + }; + + const getSpecialDay = (day: WeekDay): SpecialDay | undefined => { + if (!weekPlan) return undefined; + return weekPlan.special_days.find(s => s.day === day); + }; + + const isDaySpecial = (day: WeekDay): boolean => { + return !!getSpecialDay(day); + }; + + // Initial laden + useEffect(() => { + loadWeekPlan(); + }, [loadWeekPlan]); + + return { + weekPlan, + loading, + error, + loadWeekPlan, + createWeekPlan, + copyWeekPlan, + addEntry, + removeEntry, + updateEntry, + setSpecialDay, + removeSpecialDay, + // Helper + getEntriesForDay, + getSpecialDay, + isDaySpecial, + // Clear error + clearError: () => setError(null) + }; +} \ No newline at end of file diff --git a/frontend/src/lib/weekHelper.ts b/frontend/src/lib/weekHelper.ts new file mode 100644 index 0000000..085ff90 --- /dev/null +++ b/frontend/src/lib/weekHelper.ts @@ -0,0 +1,123 @@ +/** + * Utility-Funktionen für Kalenderwochen-Berechnungen + * Deutsche Kalenderwoche-Standards (ISO 8601) + */ + +/** + * Berechnet die aktuelle Kalenderwoche + */ +export function getCurrentWeek(): { year: number; week: number } { + const now = new Date(); + return getWeekFromDate(now); +} + +/** + * Berechnet Kalenderwoche aus einem Datum + */ +export function getWeekFromDate(date: Date): { year: number; week: number } { + const tempDate = new Date(date.valueOf()); + const dayNum = (tempDate.getDay() + 6) % 7; // Montag = 0 + + tempDate.setDate(tempDate.getDate() - dayNum + 3); + const firstThursday = tempDate.valueOf(); + tempDate.setMonth(0, 1); + + if (tempDate.getDay() !== 4) { + tempDate.setMonth(0, 1 + ((4 - tempDate.getDay()) + 7) % 7); + } + + const week = 1 + Math.ceil((firstThursday - tempDate.valueOf()) / 604800000); // 7 * 24 * 3600 * 1000 + + return { + year: tempDate.getFullYear(), + week: week + }; +} + +/** + * Berechnet das erste Datum einer Kalenderwoche + */ +export function getDateFromWeek(year: number, week: number): Date { + const date = new Date(year, 0, 1); + const dayOfWeek = date.getDay(); + const daysToMonday = dayOfWeek <= 4 ? dayOfWeek - 1 : dayOfWeek - 8; + + date.setDate(date.getDate() - daysToMonday); + date.setDate(date.getDate() + (week - 1) * 7); + + return date; +} + +/** + * Berechnet alle Tage einer Kalenderwoche (Mo-Fr) + */ +export function getWeekDays(year: number, week: number): Date[] { + const monday = getDateFromWeek(year, week); + const days: Date[] = []; + + for (let i = 0; i < 5; i++) { // Nur Mo-Fr + const day = new Date(monday); + day.setDate(monday.getDate() + i); + days.push(day); + } + + return days; +} + +/** + * Formatiert ein Datum für die Anzeige (DD.MM.) + */ +export function formatDateShort(date: Date): string { + const day = date.getDate().toString().padStart(2, '0'); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + return `${day}.${month}.`; +} + +/** + * Navigiert zur nächsten Kalenderwoche + */ +export function getNextWeek(year: number, week: number): { year: number; week: number } { + const weeksInYear = getWeeksInYear(year); + + if (week < weeksInYear) { + return { year, week: week + 1 }; + } else { + return { year: year + 1, week: 1 }; + } +} + +/** + * Navigiert zur vorherigen Kalenderwoche + */ +export function getPrevWeek(year: number, week: number): { year: number; week: number } { + if (week > 1) { + return { year, week: week - 1 }; + } else { + const prevYear = year - 1; + const weeksInPrevYear = getWeeksInYear(prevYear); + return { year: prevYear, week: weeksInPrevYear }; + } +} + +/** + * Berechnet die Anzahl Kalenderwochen in einem Jahr + */ +export function getWeeksInYear(year: number): number { + const dec31 = new Date(year, 11, 31); + const week = getWeekFromDate(dec31); + return week.year === year ? week.week : week.week - 1; +} + +/** + * Formatiert Kalenderwoche für Anzeige + */ +export function formatWeek(year: number, week: number): string { + return `KW ${week} ${year}`; +} + +/** + * Prüft ob eine Kalenderwoche existiert + */ +export function isValidWeek(year: number, week: number): boolean { + return week >= 1 && week <= getWeeksInYear(year); +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..3626ff3 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import {createRoot} from 'react-dom/client' +import './style.css' +import App from './App' + +const container = document.getElementById('root') + +const root = createRoot(container!) + +root.render( + + + +) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..3940d6c --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,26 @@ +html { + background-color: rgba(27, 38, 54, 1); + text-align: center; + color: white; +} + +body { + margin: 0; + color: white; + font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", + "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", + sans-serif; +} + +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 400; + src: local(""), + url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2"); +} + +#app { + height: 100vh; + text-align: center; +} diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css new file mode 100644 index 0000000..8850807 --- /dev/null +++ b/frontend/src/styles/globals.css @@ -0,0 +1,75 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-size: 16px; + } + + body { + background-color: #f8fafc; + color: #1f2937; + line-height: 1.6; + } + + /* Focus indicators für Barrierefreiheit */ + *:focus { + outline: 2px solid #2563eb; + outline-offset: 2px; + } + + /* Skip-to-content für Screenreader */ + .skip-link { + position: absolute; + top: -40px; + left: 6px; + background: #2563eb; + color: white; + padding: 8px; + text-decoration: none; + border-radius: 4px; + z-index: 1000; + } + + .skip-link:focus { + top: 6px; + } +} + +@layer components { + .btn { + @apply inline-flex items-center justify-center px-4 py-2 text-base font-medium rounded-md border border-transparent focus:outline-none focus:ring-2 focus:ring-offset-2 min-h-[44px] min-w-[44px]; + } + + .btn-primary { + @apply btn bg-primary text-white hover:bg-blue-700 focus:ring-primary; + } + + .btn-secondary { + @apply btn bg-white text-gray-700 border-gray-300 hover:bg-gray-50 focus:ring-gray-500; + } + + .btn-danger { + @apply btn bg-danger text-white hover:bg-red-700 focus:ring-danger; + } + + .input { + @apply block w-full px-3 py-2 text-base border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary focus:border-primary min-h-[44px]; + } + + .card { + @apply bg-white rounded-lg shadow border border-gray-200 overflow-hidden; + } + + .allergen-badge { + @apply inline-flex items-center px-2 py-1 text-xs font-medium rounded text-white; + } +} + +@layer utilities { + .text-contrast-aa { + color: #1f2937; /* Mindestens 4.5:1 Kontrast auf weißem Hintergrund */ + } +} \ No newline at end of file diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..3c70c25 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,102 @@ +// TypeScript-Interfaces basierend auf Go-Models + +export interface Allergen { + id: string; + name: string; + category: string; +} + +export interface Additive { + id: string; + name: string; +} + +export interface Product { + id: number; + name: string; + multiline: boolean; + allergens: Allergen[]; + additives: Additive[]; +} + +export interface WeekPlan { + id: number; + year: number; + week: number; + created_at: string; + entries: PlanEntry[]; + special_days: SpecialDay[]; +} + +export interface PlanEntry { + id: number; + week_plan_id: number; + day: number; // 1=Montag, 2=Dienstag, ..., 5=Freitag + meal: string; // 'fruehstueck' | 'vesper' + slot: number; + product_id?: number; + product?: Product; + custom_text?: string; + group_label?: string; // 'Krippe' | 'Kita' | 'Hort' +} + +export interface SpecialDay { + id: number; + week_plan_id: number; + day: number; // 1-5 (Mo-Fr) + type: string; // 'feiertag' | 'schliesstag' + label?: string; +} + +export interface UpdateInfo { + available: boolean; + current_version: string; + latest_version: string; + download_url?: string; + release_notes?: string; +} + +// UI-spezifische Types +export type MealType = 'fruehstueck' | 'vesper'; +export type GroupLabel = 'Krippe' | 'Kita' | 'Hort'; +export type SpecialDayType = 'feiertag' | 'schliesstag'; +export type WeekDay = 1 | 2 | 3 | 4 | 5; // Mo-Fr + +// Navigation +export type NavRoute = 'wochenplan' | 'produkte' | 'info'; + +// Form States +export interface ProductFormData { + name: string; + multiline: boolean; + allergenIds: string[]; + additiveIds: string[]; +} + +export interface AddEntryFormData { + meal: MealType; + productId?: number; + customText?: string; + groupLabel?: GroupLabel; +} + +// Konstanten für Deutsche Texte +export const DAY_NAMES: Record = { + 1: 'Montag', + 2: 'Dienstag', + 3: 'Mittwoch', + 4: 'Donnerstag', + 5: 'Freitag' +}; + +export const MEAL_NAMES: Record = { + fruehstueck: 'Frühstück', + vesper: 'Vesper' +}; + +export const GROUP_LABELS: GroupLabel[] = ['Krippe', 'Kita', 'Hort']; + +export const SPECIAL_DAY_NAMES: Record = { + feiertag: 'Feiertag', + schliesstag: 'Schließtag' +}; \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..fb54798 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,20 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + primary: '#2563EB', + danger: '#DC2626', + success: '#059669', + }, + fontSize: { + 'base': ['16px', '24px'], // Mindestens 16px für Barrierefreiheit + } + }, + }, + plugins: [], +} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..823e83d --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..b8afcc8 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": [ + "vite.config.ts" + ] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..4955065 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()] +}) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2927c9c --- /dev/null +++ b/go.mod @@ -0,0 +1,50 @@ +module speiseplan + +go 1.24.0 + +toolchain go1.24.4 + +require ( + github.com/jmoiron/sqlx v1.4.0 + github.com/wailsapp/wails/v2 v2.11.0 + modernc.org/sqlite v1.46.1 +) + +require ( + github.com/bep/debounce v1.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.22.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) + +// replace github.com/wailsapp/wails/v2 v2.11.0 => /home/clawd/go/pkg/mod diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2bc428c --- /dev/null +++ b/go.sum @@ -0,0 +1,137 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= +github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..8d47f91 --- /dev/null +++ b/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "embed" + "log" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + // Datenbank initialisieren + if err := InitDatabase(); err != nil { + log.Fatalf("Failed to initialize database: %v", err) + } + + // Sicherstellen, dass die Datenbank ordnungsgemäß geschlossen wird + defer func() { + if err := CloseDatabase(); err != nil { + log.Printf("Error closing database: %v", err) + } + }() + + // Create an instance of the app structure + app := NewApp() + + // Create application with options + err := wails.Run(&options.App{ + Title: "Speiseplan App", + Width: 1024, + Height: 768, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, + OnStartup: app.startup, + OnShutdown: func(ctx context.Context) { + // Cleanup beim Beenden + CloseDatabase() + }, + Bind: []interface{}{ + app, + }, + }) + + if err != nil { + log.Fatalf("Error running application: %v", err) + } +} diff --git a/models.go b/models.go new file mode 100644 index 0000000..5b595cf --- /dev/null +++ b/models.go @@ -0,0 +1,76 @@ +package main + +import ( + "time" +) + +// Allergen repräsentiert ein EU-Allergen (a-n) +type Allergen struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Category string `json:"category" db:"category"` +} + +// Additive repräsentiert einen Zusatzstoff (A, B, E, F, etc.) +type Additive struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` +} + +// Product repräsentiert ein Produkt mit Allergenen und Zusatzstoffen +type Product struct { + ID int `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Multiline bool `json:"multiline" db:"multiline"` + Allergens []Allergen `json:"allergens"` + Additives []Additive `json:"additives"` +} + +// WeekPlan repräsentiert einen Wochenplan +type WeekPlan struct { + ID int `json:"id" db:"id"` + Year int `json:"year" db:"year"` + Week int `json:"week" db:"week"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + Entries []PlanEntry `json:"entries"` + SpecialDays []SpecialDay `json:"special_days"` +} + +// PlanEntry repräsentiert einen Eintrag im Wochenplan +type PlanEntry struct { + ID int `json:"id" db:"id"` + WeekPlanID int `json:"week_plan_id" db:"week_plan_id"` + Day int `json:"day" db:"day"` // 0=Mo, 1=Di, 2=Mi, 3=Do, 4=Fr + Meal string `json:"meal" db:"meal"` // 'breakfast' oder 'snack' + Slot int `json:"slot" db:"slot"` // Reihenfolge innerhalb des Tages + ProductID *int `json:"product_id" db:"product_id"` + Product *Product `json:"product,omitempty"` + CustomText *string `json:"custom_text" db:"custom_text"` + GroupLabel *string `json:"group_label" db:"group_label"` // 'Krippe', 'Kita', 'Hort', etc. +} + +// SpecialDay repräsentiert einen Sondertag (Feiertag, Schließtag, etc.) +type SpecialDay struct { + ID int `json:"id" db:"id"` + WeekPlanID int `json:"week_plan_id" db:"week_plan_id"` + Day int `json:"day" db:"day"` // 0=Mo, 1=Di, ... + Type string `json:"type" db:"type"` // 'holiday' oder 'closed' + Label *string `json:"label" db:"label"` // z.B. "Neujahr", "Teamtag" +} + +// UpdateInfo repräsentiert Informationen über verfügbare Updates +type UpdateInfo struct { + Available bool `json:"available"` + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version"` + DownloadURL string `json:"download_url,omitempty"` + ReleaseNotes string `json:"release_notes,omitempty"` +} + +// ProductImport repräsentiert ein Produkt beim Import aus JSON +type ProductImport struct { + Name string `json:"name"` + Multiline bool `json:"multiline"` + Allergens1 string `json:"allergens1"` + Allergens2 string `json:"allergens2"` +} \ No newline at end of file diff --git a/products_export.json b/products_export.json new file mode 100644 index 0000000..abeaddc --- /dev/null +++ b/products_export.json @@ -0,0 +1,596 @@ +[ + { + "name": "Apfel-/Aprikosenmus", + "multiline": false, + "allergens1": "", + "allergens2": "" + }, + { + "name": "Apfel-/Bananenmus", + "multiline": false, + "allergens1": "", + "allergens2": "" + }, + { + "name": "Apfelmus & Vanillesoße", + "multiline": true, + "allergens1": "c, g, A, E, F, G, MS, S, SR, SÜ, V", + "allergens2": "" + }, + { + "name": "Bag. m. Wiener od. Salami-Sticks", + "multiline": true, + "allergens1": "a, c, i, j, k, A, E, F, GV, K, R,", + "allergens2": "SCH, SR, ST, SÜ, V" + }, + { + "name": "Baguette mit Wiener", + "multiline": true, + "allergens1": "a, c, i, j, k, E, F, GV, K, R, SCH,", + "allergens2": "SR, ST, SÜ, V" + }, + { + "name": "Baumkuchen", + "multiline": false, + "allergens1": "a, c, e, f, g, h, k,", + "allergens2": "E, FH, SR, SÜ" + }, + { + "name": "belegte Baguettes", + "multiline": false, + "allergens1": "a, c, g, i, j, k,", + "allergens2": "E, F, GV, K, R, SCH, SR, ST, SÜ, V" + }, + { + "name": "belegte Brötchen", + "multiline": false, + "allergens1": "a, c, g, i, j, k,", + "allergens2": "E, F, GV, K, R, SCH, SR, ST, SÜ, V" + }, + { + "name": "belegtes Baguette", + "multiline": false, + "allergens1": "a, c, g, i, j, k,", + "allergens2": "E, F, GV, K, R, SCH, SR, ST, SÜ, V" + }, + { + "name": "belegtes Toast", + "multiline": false, + "allergens1": "a, c, g, i, j, k,", + "allergens2": "E, F, GV, K, R, SCH, SR, ST, SÜ, V" + }, + { + "name": "Bemmchen", + "multiline": true, + "allergens1": "a, c, f, g, h, i, j, k, m, B", + "allergens2": "" + }, + { + "name": "Blätterteig-Zimtschnecken", + "multiline": true, + "allergens1": "a, c, B, E, S, SÜ", + "allergens2": "" + }, + { + "name": "Brötchen mit süßem Aufstrich", + "multiline": true, + "allergens1": "a, c, g, B, SÜ", + "allergens2": "" + }, + { + "name": "Butter", + "multiline": false, + "allergens1": "g", + "allergens2": "" + }, + { + "name": "Cornflakes", + "multiline": false, + "allergens1": "a, e, g, f, h, k, SÜ", + "allergens2": "" + }, + { + "name": "Donuts", + "multiline": false, + "allergens1": "a, h, B, E, ST, SÜ", + "allergens2": "" + }, + { + "name": "Eier", + "multiline": false, + "allergens1": "c", + "allergens2": "" + }, + { + "name": "Eierplätzchen", + "multiline": false, + "allergens1": "a, c, g, B", + "allergens2": "" + }, + { + "name": "Eis", + "multiline": false, + "allergens1": "e, f, g, h, E, ST, SÜ", + "allergens2": "" + }, + { + "name": "Erdbeerquark", + "multiline": false, + "allergens1": "g, SÜ", + "allergens2": "" + }, + { + "name": "Filinchen", + "multiline": false, + "allergens1": "a, f, g, SÜ", + "allergens2": "" + }, + { + "name": "Filinchen / Knäckebrot", + "multiline": false, + "allergens1": "a, f, g, SÜ", + "allergens2": "" + }, + { + "name": "Filinchen mit Marmelade", + "multiline": true, + "allergens1": "a, c, f, g, B, SÜ", + "allergens2": "" + }, + { + "name": "Frischkäse", + "multiline": false, + "allergens1": "g", + "allergens2": "" + }, + { + "name": "Fruchtjoghurt", + "multiline": false, + "allergens1": "g, F, MS, SR, SÜ, V", + "allergens2": "" + }, + { + "name": "Fruchtquark", + "multiline": false, + "allergens1": "g, F, SÜ", + "allergens2": "" + }, + { + "name": "Fruchtreiswaffeln", + "multiline": false, + "allergens1": "f, g, h, k, SÜ", + "allergens2": "" + }, + { + "name": "Fruchtriegel", + "multiline": false, + "allergens1": "e, f, g, h, k, F, S, SÜ", + "allergens2": "" + }, + { + "name": "Fruchtzwerge", + "multiline": false, + "allergens1": "g, F, SÜ", + "allergens2": "" + }, + { + "name": "Götterspeise mit Vanillesoße", + "multiline": true, + "allergens1": "g, SÜ, G, S, F, SV", + "allergens2": "" + }, + { + "name": "Haferflocken", + "multiline": false, + "allergens1": "a", + "allergens2": "" + }, + { + "name": "IKEA-Hot-Dogs", + "multiline": true, + "allergens1": "a, i, j, GV, K, S, ST, SÜ", + "allergens2": "" + }, + { + "name": "Joghurt", + "multiline": false, + "allergens1": "g, E, MS, SR, SÜ, V", + "allergens2": "" + }, + { + "name": "Joghurt mit Obstsalat", + "multiline": true, + "allergens1": "g, E, MS, SR, SÜ, V", + "allergens2": "" + }, + { + "name": "Kekse", + "multiline": false, + "allergens1": "a, c, e, f, g, i, j, k,", + "allergens2": "B, E, F, SÜ, TM" + }, + { + "name": "kleine Puddings", + "multiline": false, + "allergens1": "c, g, F, SÜ", + "allergens2": "" + }, + { + "name": "kleine Quarks", + "multiline": false, + "allergens1": "g, MS, SÜ, V", + "allergens2": "" + }, + { + "name": "Kuchen", + "multiline": false, + "allergens1": "a, c, g, h, B, E, SÜ", + "allergens2": "" + }, + { + "name": "Laugenbrezeln", + "multiline": false, + "allergens1": "a, g, k, SR", + "allergens2": "" + }, + { + "name": "Laugengebäck", + "multiline": false, + "allergens1": "a, g, k, SR", + "allergens2": "" + }, + { + "name": "Laugenstangen", + "multiline": false, + "allergens1": "a, g, k, SR", + "allergens2": "" + }, + { + "name": "Leckermäulchen", + "multiline": false, + "allergens1": "g, F, G, MS, SÜ, V", + "allergens2": "" + }, + { + "name": "Madeleines", + "multiline": false, + "allergens1": "a, c, g, B, FH, SÜ", + "allergens2": "" + }, + { + "name": "Maisstangen (glutenfrei)", + "multiline": false, + "allergens1": "", + "allergens2": "" + }, + { + "name": "Milch", + "multiline": false, + "allergens1": "g", + "allergens2": "" + }, + { + "name": "Milch / Joghurt", + "multiline": false, + "allergens1": "g", + "allergens2": "" + }, + { + "name": "Milchbrötchen", + "multiline": false, + "allergens1": "a, c, g, B, SÜ", + "allergens2": "" + }, + { + "name": "Milchreis", + "multiline": false, + "allergens1": "g, MS, SÜ", + "allergens2": "" + }, + { + "name": "mini Paula", + "multiline": false, + "allergens1": "g, F, MS, ST, SÜ, V", + "allergens2": "" + }, + { + "name": "Mini-Amerikaner", + "multiline": true, + "allergens1": "a, c, g, h, B, E, MS, SÜ, V", + "allergens2": "" + }, + { + "name": "Mini-Berliner", + "multiline": false, + "allergens1": "a, c, g, h,", + "allergens2": "B, E, SR, SÜ, V" + }, + { + "name": "Mini-Donuts", + "multiline": false, + "allergens1": "a, h, B, E, ST, SÜ", + "allergens2": "" + }, + { + "name": "Mini-Eclairs", + "multiline": true, + "allergens1": "a, c, f, g, h, E, MS, ST, SÜ", + "allergens2": "" + }, + { + "name": "Mini-Geflügelfrikadellen", + "multiline": true, + "allergens1": "a, c, j, B, SÜ", + "allergens2": "" + }, + { + "name": "Mini-Götterspeise / Vanillesoße", + "multiline": true, + "allergens1": "g, SÜ, G, S, F, SV", + "allergens2": "" + }, + { + "name": "Mini-Muffins", + "multiline": true, + "allergens1": "a, c, f, g, B, E, FH, SÜ, V", + "allergens2": "" + }, + { + "name": "Mini-Quarkbällchen", + "multiline": true, + "allergens1": "a, c, g, h, B, E, St, SÜ, V", + "allergens2": "" + }, + { + "name": "Mini-Windbeutel", + "multiline": false, + "allergens1": "a, c, g, h,", + "allergens2": "B, E, SR, SÜ, V" + }, + { + "name": "Monte", + "multiline": false, + "allergens1": "g, h, MS, SÜ, V", + "allergens2": "" + }, + { + "name": "Nutella", + "multiline": false, + "allergens1": "g, f, h, E, SÜ", + "allergens2": "" + }, + { + "name": "Nutellabaguette", + "multiline": false, + "allergens1": "a, c, f, g,", + "allergens2": "E, SR, SÜ, V" + }, + { + "name": "Nutellabrot", + "multiline": false, + "allergens1": "a, c, f, g,", + "allergens2": "E, SR, SÜ, V" + }, + { + "name": "Nutella-Toast", + "multiline": false, + "allergens1": "a, c, f, g,", + "allergens2": "E, SR, SÜ, V" + }, + { + "name": "Obst", + "multiline": false, + "allergens1": "", + "allergens2": "" + }, + { + "name": "Obst / Gemüse", + "multiline": false, + "allergens1": "", + "allergens2": "" + }, + { + "name": "Obstpause", + "multiline": false, + "allergens1": "", + "allergens2": "" + }, + { + "name": "Obstsalat", + "multiline": false, + "allergens1": "", + "allergens2": "" + }, + { + "name": "Pizzabrötchen", + "multiline": false, + "allergens1": "a, c, g, B", + "allergens2": "" + }, + { + "name": "Plinse mit Zucker & Zimt", + "multiline": true, + "allergens1": "a, c, g, B, MS, SÜ", + "allergens2": "" + }, + { + "name": "Pudding", + "multiline": false, + "allergens1": "c, g, F, SÜ", + "allergens2": "" + }, + { + "name": "Quarkkuchen", + "multiline": true, + "allergens1": "a, c, g, h, B, E, MS, SÜ, V", + "allergens2": "" + }, + { + "name": "Quarkspeise", + "multiline": false, + "allergens1": "g, SÜ", + "allergens2": "" + }, + { + "name": "Reiswaffeln", + "multiline": false, + "allergens1": "f, g, k", + "allergens2": "" + }, + { + "name": "Reiswaffeln mit Schokolade", + "multiline": true, + "allergens1": "f, g, k, SÜ", + "allergens2": "" + }, + { + "name": "Rote Grütze & Vanillesoße", + "multiline": true, + "allergens1": "g, E, F, G, MS, S, SR, SÜ, V", + "allergens2": "" + }, + { + "name": "Rührei", + "multiline": false, + "allergens1": "c, g", + "allergens2": "" + }, + { + "name": "Rührkuchen", + "multiline": false, + "allergens1": "a, c, g, h, B, E, SÜ", + "allergens2": "" + }, + { + "name": "Salzgebäck", + "multiline": false, + "allergens1": "a, c, g, k, B, SR", + "allergens2": "" + }, + { + "name": "Schmierkäse", + "multiline": false, + "allergens1": "g, SCH, SR", + "allergens2": "" + }, + { + "name": "Schnitt- & Schmierkäse", + "multiline": false, + "allergens1": "g, SCH, SR", + "allergens2": "" + }, + { + "name": "Schnittkäse", + "multiline": false, + "allergens1": "g", + "allergens2": "" + }, + { + "name": "Schokobrötchen", + "multiline": true, + "allergens1": "a, c, g, k, B ,E, SÜ, V", + "allergens2": "" + }, + { + "name": "Schokopudding & Vanillesoße", + "multiline": true, + "allergens1": "c, g, E, F, G, MS, S, SR, SÜ, V", + "allergens2": "" + }, + { + "name": "Stracciatella-Joghurt", + "multiline": true, + "allergens1": "g, MS, SR, SÜ, V", + "allergens2": "" + }, + { + "name": "süßer Aufstrich", + "multiline": false, + "allergens1": "a, e, g, E, G, SÜ", + "allergens2": "" + }, + { + "name": "süßer Quark", + "multiline": false, + "allergens1": "g, SÜ", + "allergens2": "" + }, + { + "name": "süsses Baguette", + "multiline": false, + "allergens1": "a, c, f, g,", + "allergens2": "E, G, SR, SÜ, V" + }, + { + "name": "süßes Toastbrot", + "multiline": true, + "allergens1": "a, c, g, B, SÜ", + "allergens2": "" + }, + { + "name": "Toast Hawai", + "multiline": true, + "allergens1": "a, c, g, GV, K, SR, ST, SÜ", + "allergens2": "" + }, + { + "name": "Toast mit Wiener", + "multiline": true, + "allergens1": "a, c, g, i, j, k, GV, K, SR, ST, SÜ", + "allergens2": "" + }, + { + "name": "Toast überbacken", + "multiline": false, + "allergens1": "a, c, g, i, j, k,", + "allergens2": "E, F, GV, K, R, SCH, SR, ST, SÜ, V" + }, + { + "name": "Vanillesoße", + "multiline": true, + "allergens1": "c, g, A, E, F, G, MS, S, SR, SÜ, V", + "allergens2": "" + }, + { + "name": "verschiedenes Brot", + "multiline": false, + "allergens1": "a, c, k, B", + "allergens2": "" + }, + { + "name": "Waffeln", + "multiline": false, + "allergens1": "a, c, m, B, E, FH, SR, SÜ", + "allergens2": "" + }, + { + "name": "Wiener", + "multiline": false, + "allergens1": "i, j, E, F, GV,", + "allergens2": "K, SCH, SR, ST, V" + }, + { + "name": "Wiener & Toastbrot", + "multiline": true, + "allergens1": "a, c, i, j, k, E, F, GV, K, R, SCH,", + "allergens2": "SR, ST, SÜ, V" + }, + { + "name": "Wurst", + "multiline": false, + "allergens1": "i, j, E, F, GV, K, SR, ST, V", + "allergens2": "" + }, + { + "name": "Würstchen im Schlafrock (Blätter-", + "multiline": true, + "allergens1": "teig) (a, c, g, i, j, E, F, GV,", + "allergens2": "B, E, K, S, SCH, SR, ST, SÜ, V" + }, + { + "name": "Zwieback", + "multiline": false, + "allergens1": "a, B, M, SÜ", + "allergens2": "" + } +] \ No newline at end of file diff --git a/seed.go b/seed.go new file mode 100644 index 0000000..dd505d8 --- /dev/null +++ b/seed.go @@ -0,0 +1,221 @@ +package main + +import ( + _ "embed" + "encoding/json" + "fmt" + "regexp" + "strings" +) + +//go:embed products_export.json +var productsJSON []byte + +// SeedDatabase fügt Seed-Daten in die Datenbank ein +func SeedDatabase() error { + // Prüfen ob bereits Seeds vorhanden sind + var count int + err := db.Get(&count, "SELECT COUNT(*) FROM allergens") + if err != nil { + return fmt.Errorf("failed to check for existing allergens: %w", err) + } + + // Nur seeden wenn noch keine Daten vorhanden sind + if count > 0 { + return nil // Bereits geseedet + } + + // Allergene seeden + if err := seedAllergens(); err != nil { + return fmt.Errorf("failed to seed allergens: %w", err) + } + + // Zusatzstoffe seeden + if err := seedAdditives(); err != nil { + return fmt.Errorf("failed to seed additives: %w", err) + } + + // Produkte seeden + if err := seedProducts(); err != nil { + return fmt.Errorf("failed to seed products: %w", err) + } + + return nil +} + +// seedAllergens fügt die 14 EU-Allergene ein +func seedAllergens() error { + allergens := []Allergen{ + {"a", "glutenhaltiges Getreide (Weizen, Roggen, Gerste, Hafer, Dinkel, Kamut)", "allergen"}, + {"b", "Krebstiere", "allergen"}, + {"c", "Eier", "allergen"}, + {"d", "Fisch", "allergen"}, + {"e", "Erdnüsse", "allergen"}, + {"f", "Soja(bohnen)", "allergen"}, + {"g", "Milch (einschließlich Laktose)", "allergen"}, + {"h", "Schalenfrüchte (Mandeln, Haselnüsse, Walnüsse, Cashew, Pecan, Paranüsse, Pistazien, Macadamia)", "allergen"}, + {"i", "Sellerie", "allergen"}, + {"j", "Senf", "allergen"}, + {"k", "Sesamsamen", "allergen"}, + {"l", "Schwefeldioxid und Sulfite (> 10 mg/kg oder mg/l)", "allergen"}, + {"m", "Lupine", "allergen"}, + {"n", "Weichtiere", "allergen"}, + } + + for _, allergen := range allergens { + _, err := db.NamedExec( + "INSERT OR IGNORE INTO allergens (id, name, category) VALUES (:id, :name, :category)", + allergen, + ) + if err != nil { + return fmt.Errorf("failed to insert allergen %s: %w", allergen.ID, err) + } + } + + return nil +} + +// seedAdditives fügt die deutschen Zusatzstoffe ein +func seedAdditives() error { + additives := []Additive{ + {"A", "Antioxidationsmittel"}, + {"B", "Backtriebmittel"}, + {"E", "Emulgator"}, + {"F", "Farbstoff"}, + {"FM", "Festigungsmittel"}, + {"FH", "Feuchthaltemittel"}, + {"FÜ", "Füllstoff"}, + {"G", "Geliermittel"}, + {"GV", "Geschmacksverstärker"}, + {"K", "Konservierungsstoff"}, + {"M", "Mehlbehandlungsmittel"}, + {"MS", "Modifizierte Stärke"}, + {"R", "Rieselhilfe"}, + {"S", "Säuerungsmittel"}, + {"SR", "Säureregulator"}, + {"SV", "Schaumverhüter"}, + {"SCH", "Schmelzsalz"}, + {"ST", "Stabilisator"}, + {"SÜ", "Süßungsmittel"}, + {"T", "Trägerstoff"}, + {"TG", "Treibgas"}, + {"TM", "Trennmittel"}, + {"Ü", "Überzugsmittel"}, + {"V", "Verdickungsmittel"}, + } + + for _, additive := range additives { + _, err := db.NamedExec( + "INSERT OR IGNORE INTO additives (id, name) VALUES (:id, :name)", + additive, + ) + if err != nil { + return fmt.Errorf("failed to insert additive %s: %w", additive.ID, err) + } + } + + return nil +} + +// seedProducts importiert Produkte aus der JSON-Datei +func seedProducts() error { + var imports []ProductImport + if err := json.Unmarshal(productsJSON, &imports); err != nil { + return fmt.Errorf("failed to parse products JSON: %w", err) + } + + tx, err := db.Beginx() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + for _, product := range imports { + // Produkt einfügen + result, err := tx.NamedExec( + "INSERT OR IGNORE INTO products (name, multiline) VALUES (:name, :multiline)", + map[string]interface{}{ + "name": product.Name, + "multiline": product.Multiline, + }, + ) + if err != nil { + return fmt.Errorf("failed to insert product %s: %w", product.Name, err) + } + + // Produkt-ID ermitteln + var productID int64 + if productID, err = result.LastInsertId(); err != nil { + // Wenn INSERT OR IGNORE nichts eingefügt hat, ID über SELECT holen + err = tx.Get(&productID, "SELECT id FROM products WHERE name = ?", product.Name) + if err != nil { + return fmt.Errorf("failed to get product ID for %s: %w", product.Name, err) + } + } + + // Allergene und Zusatzstoffe parsen und zuordnen + allergenIDs, additiveIDs := parseAllergenData(product.Allergens1, product.Allergens2) + + // Allergene zuordnen + for _, allergenID := range allergenIDs { + _, err := tx.Exec( + "INSERT OR IGNORE INTO product_allergens (product_id, allergen_id) VALUES (?, ?)", + productID, allergenID, + ) + if err != nil { + return fmt.Errorf("failed to link allergen %s to product %s: %w", allergenID, product.Name, err) + } + } + + // Zusatzstoffe zuordnen + for _, additiveID := range additiveIDs { + _, err := tx.Exec( + "INSERT OR IGNORE INTO product_additives (product_id, additive_id) VALUES (?, ?)", + productID, additiveID, + ) + if err != nil { + return fmt.Errorf("failed to link additive %s to product %s: %w", additiveID, product.Name, err) + } + } + } + + return tx.Commit() +} + +// parseAllergenData parst Allergen- und Zusatzstoff-Daten aus den Import-Feldern +func parseAllergenData(allergens1, allergens2 string) ([]string, []string) { + // Beide Felder zusammenführen + combined := strings.TrimSpace(allergens1 + ", " + allergens2) + if combined == ", " { + return nil, nil + } + + // Regex für Allergene (einzelne Kleinbuchstaben a-n) + allergenRegex := regexp.MustCompile(`\b[a-n]\b`) + // Regex für Zusatzstoffe (Großbuchstaben und Kürzel) + additiveRegex := regexp.MustCompile(`\b[A-Z]{1,3}\b`) + + allergenMatches := allergenRegex.FindAllString(combined, -1) + additiveMatches := additiveRegex.FindAllString(combined, -1) + + // Duplikate entfernen + allergenIDs := removeDuplicates(allergenMatches) + additiveIDs := removeDuplicates(additiveMatches) + + return allergenIDs, additiveIDs +} + +// removeDuplicates entfernt Duplikate aus einem String-Slice +func removeDuplicates(slice []string) []string { + seen := make(map[string]bool) + result := []string{} + + for _, item := range slice { + if !seen[item] { + seen[item] = true + result = append(result, item) + } + } + + return result +} \ No newline at end of file diff --git a/updater.go b/updater.go new file mode 100644 index 0000000..6dd673f --- /dev/null +++ b/updater.go @@ -0,0 +1,105 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +const ( + // Aktuelle Version der App + CurrentVersion = "1.0.0" + // Update-Check URL + UpdateURL = "https://speiseplan.supertoll.xyz/version.json" +) + +// Updater verwaltet App-Updates +type Updater struct { + CurrentVersion string + UpdateURL string + HTTPClient *http.Client +} + +// NewUpdater erstellt einen neuen Updater +func NewUpdater() *Updater { + return &Updater{ + CurrentVersion: CurrentVersion, + UpdateURL: UpdateURL, + HTTPClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// VersionResponse repräsentiert die Antwort vom Update-Server +type VersionResponse struct { + Version string `json:"version"` + DownloadURL string `json:"download_url"` + ReleaseNotes string `json:"release_notes"` +} + +// CheckForUpdate prüft ob ein Update verfügbar ist +func (u *Updater) CheckForUpdate() (*UpdateInfo, error) { + resp, err := u.HTTPClient.Get(u.UpdateURL) + if err != nil { + return nil, fmt.Errorf("failed to check for updates: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("update server returned status %d", resp.StatusCode) + } + + var versionResp VersionResponse + if err := json.NewDecoder(resp.Body).Decode(&versionResp); err != nil { + return nil, fmt.Errorf("failed to parse version response: %w", err) + } + + // Versions-Vergleich (vereinfacht) + available := compareVersions(u.CurrentVersion, versionResp.Version) < 0 + + return &UpdateInfo{ + Available: available, + CurrentVersion: u.CurrentVersion, + LatestVersion: versionResp.Version, + DownloadURL: versionResp.DownloadURL, + ReleaseNotes: versionResp.ReleaseNotes, + }, nil +} + +// DownloadUpdate lädt ein verfügbares Update herunter (Stub) +func (u *Updater) DownloadUpdate(downloadURL string) error { + // TODO: Implementierung für das Herunterladen und Installieren von Updates + // Für Phase 1 nur ein Stub + return fmt.Errorf("update download not implemented yet") +} + +// compareVersions vergleicht zwei Versionsstrings +// Gibt -1 zurück wenn v1 < v2, 0 wenn v1 == v2, 1 wenn v1 > v2 +func compareVersions(v1, v2 string) int { + // Vereinfachter Versionsvergleich (nur für grundlegende Semantic Versioning) + v1Parts := strings.Split(strings.TrimPrefix(v1, "v"), ".") + v2Parts := strings.Split(strings.TrimPrefix(v2, "v"), ".") + + // Normalisiere auf gleiche Länge + for len(v1Parts) < 3 { + v1Parts = append(v1Parts, "0") + } + for len(v2Parts) < 3 { + v2Parts = append(v2Parts, "0") + } + + for i := 0; i < 3; i++ { + // Einfacher String-Vergleich (funktioniert für einstellige Zahlen) + if v1Parts[i] < v2Parts[i] { + return -1 + } + if v1Parts[i] > v2Parts[i] { + return 1 + } + } + + return 0 +} \ No newline at end of file diff --git a/wails.json b/wails.json new file mode 100644 index 0000000..1b0f164 --- /dev/null +++ b/wails.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "speiseplan", + "outputfilename": "speiseplan", + "frontend:install": "npm install", + "frontend:build": "npm run build", + "frontend:dev:watcher": "npm run dev", + "frontend:dev:serverUrl": "auto", + "author": { + "name": "", + "email": "" + } +}