feat(web-app): Implement standard item list and improved item adding logic (see diff for details)

This commit is contained in:
2025-10-22 21:46:59 +02:00
parent 312477ab19
commit fae079b77f
3 changed files with 254 additions and 0 deletions

92
web-app/main.py Normal file
View File

@@ -0,0 +1,92 @@
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import sqlite3
import uvicorn
import requests
import os
app = FastAPI()
# --- Gotify Konfiguration ---
# Die Gotify URL wird aus der Umgebungsvariable GOTIFY_URL gelesen.
# Beispiel: https://gotify.example.com/message?token=MY_TOKEN
GOTIFY_URL = os.getenv("GOTIFY_URL")
def send_gotify_notification(title: str, message: str):
if not GOTIFY_URL:
print("GOTIFY_URL ist nicht gesetzt. Überspringe Benachrichtigung.")
return
try:
payload = {"title": title, "message": message}
requests.post(GOTIFY_URL, json=payload)
print("Gotify-Benachrichtigung gesendet.")
except Exception as e:
print(f"Fehler beim Senden der Gotify-Benachrichtigung: {e}")
from translations import get_standard_items
# --- Datenbank-Setup ---
def init_db():
conn = sqlite3.connect("shoppinglist.db")
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
)
""")
conn.commit()
conn.close()
print("Datenbank initialisiert.")
@app.get("/api/standard-items/{lang}")
async def get_standard_items_route(lang: str):
return get_standard_items(lang)
# --- Datenmodelle ---
class Item(BaseModel):
name: str
# --- API Endpunkte ---
@app.get("/api/items")
async def get_items():
conn = sqlite3.connect("shoppinglist.db")
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT * FROM items ORDER BY name")
items = cursor.fetchall()
conn.close()
return {"items": [dict(row) for row in items]}
@app.post("/api/items")
async def add_item(item: Item):
conn = sqlite3.connect("shoppinglist.db")
cursor = conn.cursor()
try:
cursor.execute("INSERT INTO items (name) VALUES (?)", (item.name,))
conn.commit()
except sqlite3.IntegrityError:
conn.close()
raise HTTPException(status_code=400, detail="Item already exists")
finally:
conn.close()
return {"status": "ok"}
@app.delete("/api/items/{item_id}")
async def delete_item(item_id: int):
conn = sqlite3.connect("shoppinglist.db")
cursor = conn.cursor()
cursor.execute("DELETE FROM items WHERE id = ?", (item_id,))
conn.commit()
conn.close()
return {"status": "ok"}
@app.post("/api/notify")
async def trigger_notification():
send_gotify_notification("Einkaufsliste aktualisiert", "Die Liste wurde von einem Benutzer geändert.")
return {"status": "notification sent"}
# --- Statische Dateien (Frontend) ---
app.mount("/", StaticFiles(directory="static", html=True), name="static")

139
web-app/static/index.html Normal file
View File

@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Geteilte Einkaufsliste</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: auto; padding: 20px; }
h1 { text-align: center; }
ul { list-style-type: none; padding: 0; }
li { display: flex; justify-content: space-between; align-items: center; padding: 10px; border-bottom: 1px solid #ddd; }
.delete-btn { cursor: pointer; color: red; }
form { display: flex; margin-top: 20px; }
input[type="text"] { flex-grow: 1; padding: 10px; }
button { padding: 10px; }
#notify-btn { width: 100%; margin-top: 15px; background-color: #28a745; color: white; border: none; }
</style>
</head>
<body>
<h1>Geteilte Einkaufsliste</h1>
<ul id="item-list"></ul>
<form id="add-item-form">
<input type="text" id="item-name" placeholder="Neuer Artikel..." list="standard-items" required>
<datalist id="standard-items"></datalist>
<button type="submit">Hinzufügen</button>
<button type="button" id="add-combined-btn">+1</button>
</form>
<button id="notify-btn">Änderungen abschließen & Benachrichtigen</button>
<script>
const itemList = document.getElementById('item-list');
const addItemForm = document.getElementById('add-item-form');
const itemNameInput = document.getElementById('item-name');
const notifyBtn = document.getElementById('notify-btn');
const standardItemsDatalist = document.getElementById('standard-items');
const addCombinedBtn = document.getElementById('add-combined-btn');
let standardItems = [];
// --- Funktionen ---
async function fetchStandardItems() {
const lang = navigator.language.startsWith('de') ? 'de' : 'en';
const response = await fetch(`/api/standard-items/${lang}`);
standardItems = await response.json();
standardItemsDatalist.innerHTML = standardItems.map(item => `<option value="${item}">`).join('');
}
async function fetchItems() {
const response = await fetch('/api/items');
const data = await response.json();
itemList.innerHTML = ''; // Liste leeren
data.items.forEach(item => {
const li = document.createElement('li');
li.textContent = item.name;
const deleteBtn = document.createElement('span');
deleteBtn.textContent = 'Löschen';
deleteBtn.className = 'delete-btn';
deleteBtn.onclick = () => deleteItem(item.id);
li.appendChild(deleteBtn);
itemList.appendChild(li);
});
}
async function addItem(name) {
if (!name) return;
await fetch('/api/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name })
});
itemNameInput.value = '';
fetchItems();
}
async function addItemsFromInput(inputString, splitByComma) {
if (!inputString.trim()) return;
let itemsToAdd = [];
if (splitByComma && inputString.includes(',')) {
itemsToAdd = inputString.split(',').map(item => item.trim()).filter(item => item);
} else {
itemsToAdd.push(inputString.trim());
}
for (const item of itemsToAdd) {
await addItem(item);
}
}
async function deleteItem(id) {
await fetch(`/api/items/${id}`, { method: 'DELETE' });
fetchItems();
}
async function sendNotification() {
notifyBtn.textContent = 'Sende...';
await fetch('/api/notify', { method: 'POST' });
notifyBtn.textContent = 'Benachrichtigung gesendet!';
setTimeout(() => {
notifyBtn.textContent = 'Änderungen abschließen & Benachrichtigen';
}, 2000);
}
// --- Event Listeners ---
addItemForm.addEventListener('submit', async (event) => {
event.preventDefault();
await addItemsFromInput(itemNameInput.value, true);
});
itemNameInput.addEventListener('keydown', async (event) => {
if (event.key === 'Enter') {
event.preventDefault();
await addItemsFromInput(itemNameInput.value, true);
}
});
addCombinedBtn.addEventListener('click', async () => {
await addItemsFromInput(itemNameInput.value, false);
});
notifyBtn.addEventListener('click', sendNotification);
// --- Initialisierung ---
fetchStandardItems();
fetchItems();
</script>
</body>
</html>

23
web-app/translations.py Normal file
View File

@@ -0,0 +1,23 @@
import xml.etree.ElementTree as ET
from typing import List
def get_standard_items(lang: str) -> List[str]:
"""
Parses the strings.xml file for the given language and returns the standard shopping list items.
"""
if lang == "de":
filepath = "../app/src/main/res/values-de/strings.xml"
else:
filepath = "../app/src/main/res/values/strings.xml"
try:
tree = ET.parse(filepath)
root = tree.getroot()
for string_array in root.findall('string-array'):
if string_array.get('name') == 'standard_list_items':
return [item.text for item in string_array.findall('item')]
except (ET.ParseError, FileNotFoundError):
return []
return []