feat: Initial commit for web-app

This commit is contained in:
2025-10-26 10:22:08 +01:00
commit 8d8ed476fb
12 changed files with 1824 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
data/

35
Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# 1. Basis-Image
FROM python:3.11-slim
# Install su-exec, a lightweight tool for dropping privileges
RUN apt-get update && apt-get install -y --no-install-recommends gosu && rm -rf /var/lib/apt/lists/*
# 2. Arbeitsverzeichnis im Container setzen
WORKDIR /app
# 3. Abhängigkeiten installieren
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 4. Anwendungs-Code kopieren
COPY . .
# Create the data directory that will be used by the volume
# The entrypoint script will set the correct permissions on this
RUN mkdir -p /app/data
# Copy and set permissions for the entrypoint script
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
# 5. Port freigeben, auf dem die App läuft
EXPOSE 8000
# 6. Umgebungsvariable für die Gotify-URL (kann beim Start überschrieben werden)
ENV GOTIFY_URL="https://gotify.ilunix.de/message?token=ADhlhgJ2Z14HK70"
# 7. Entrypoint und Befehl zum Starten der Anwendung
# The entrypoint script will handle user permissions and then run the CMD
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

52
README.md Normal file
View File

@@ -0,0 +1,52 @@
# Shared Shopping List Web App
## About The Project
This project provides a central, self-hosted web application for a family or group to manage a shared shopping list. Any member can easily add items they need, creating a single, always-up-to-date list.
This web service is the final development step for the accompanying **Android app**. The goal is to allow the app to automatically sync with this web service.
**Current Status & Workaround:** While direct API integration in the app is still to be completed, the system is already fully functional using notifications. The text sent in the Gotify notification can be copied and pasted 1:1 into the Android app to instantly populate the shopping list.
## How to Use
Here is an example `docker-compose.yml` file:
```yaml
services:
shopping-list:
image: lxtools/noteshop-webapp:latest
container_name: shopping-list
restart: always
ports:
- "8080:8000" # Host-Port:Container-Port
volumes:
# For local development, use a relative path:
- ./data:/app/data
# For production, you might use an absolute path like this (choose one):
# - /opt/containers/sharelist/data:/app/data
environment:
# Set the User and Group ID for file permissions.
# Defaults to 1000 if not specified in your environment.
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
# Your Gotify URL for notifications. Should be set in a .env file.
- GOTIFY_URL=${GOTIFY_URL}
```
### First Login
After starting the container for the first time, a default administrator account is created so you can log in.
* **Username:** `admin`
* **Password:** `admin`
**Important:** For security reasons, please log in and change the default password immediately using the admin panel.
### Important Notes
* **User/Group:** Set `PUID` and `PGID` to your user's ID on the host system. You can find them by running the commands `id -u` and `id -g`.
* **Data Directory:** Before the first run, create a directory for the database: `mkdir data`.
* **Running:** Start the container with: `PUID=$(id -u) PGID=$(id -g) docker compose up -d`

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
shopping-list:
build: .
container_name: shopping-list
restart: always
ports:
- "82:8000" # Host-Port:Container-Port
volumes:
# For local development, use a relative path:
- ./data:/app/data
# For production, you might use an absolute path like this (choose one):
# - /opt/containers/sharelist/data:/app/data
environment:
# Set the User and Group ID for file permissions.
# Defaults to 1000 if not specified in your environment.
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
# Your Gotify URL for notifications. Should be set in a .env file.
- GOTIFY_URL=${GOTIFY_URL}

15
entrypoint.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
# Exit on error
set -e
# Use PUID/PGID from environment variables, or default to 1000
PUID=${PUID:-1000}
PGID=${PGID:-1000}
# Set ownership of the data directory
# This ensures that the user running the container can write to the volume
chown -R "$PUID:$PGID" /app/data
# Drop root privileges and execute the main command (CMD)
# The command (e.g., uvicorn) is passed as arguments to this script ("$@")
exec gosu "$PUID:$PGID" "$@"

556
main.py Normal file
View File

@@ -0,0 +1,556 @@
from fastapi import FastAPI, HTTPException, Depends, status, WebSocket, WebSocketDisconnect, Request
from fastapi.staticfiles import StaticFiles
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, timedelta
from jose import JWTError, jwt
import sqlite3
import uvicorn
import requests
import os
from passlib.context import CryptContext
from collections import defaultdict
app = FastAPI()
# --- Configuration ---
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 43200 # 30 days
DB_FILE = "data/shoppinglist.db"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
# --- Brute-Force Protection ---
MAX_FAILED_ATTEMPTS_USER = 5
MAX_FAILED_ATTEMPTS_ADMIN = 3
LOCKOUT_PERIOD_MINUTES = 30
failed_login_attempts_ip = defaultdict(lambda: {'count': 0, 'timestamp': datetime.utcnow()})
# --- Pydantic Models ---
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None
class User(BaseModel):
username: str
is_admin: bool
class UserInDB(User):
id: int
password_hash: str
failed_login_attempts: int
is_locked: bool
locked_at: Optional[datetime]
class Item(BaseModel):
names: List[str]
class NewUser(BaseModel):
username: str
password: str
is_admin: bool = False
# --- WebSocket Connection Manager ---
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
self.user_map: dict[WebSocket, User] = {}
async def connect(self, websocket: WebSocket, user: User):
await websocket.accept()
self.active_connections.append(websocket)
self.user_map[websocket] = user
await self.broadcast_user_list()
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
if websocket in self.user_map:
del self.user_map[websocket]
async def broadcast_user_list(self):
user_list = sorted([user.username for user in self.user_map.values()])
for connection in self.active_connections:
await connection.send_json({"type": "user_list", "users": user_list})
manager = ConnectionManager()
# --- Auth Helper Functions ---
def get_user(username: str):
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
user_data = cursor.fetchone()
conn.close()
if user_data:
user_dict = dict(user_data)
if user_dict.get('locked_at'):
try:
user_dict['locked_at'] = datetime.fromisoformat(user_dict['locked_at'])
except (TypeError, ValueError):
user_dict['locked_at'] = None
return UserInDB(**user_dict)
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_active_user(token: str = Depends(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except JWTError:
raise credentials_exception
user = get_user(username=token_data.username)
if user is None:
raise credentials_exception
return user
# --- Gotify Konfiguration ---
GOTIFY_URL = os.getenv("GOTIFY_URL")
def send_gotify_notification(title: str, message: str) -> str:
if not GOTIFY_URL:
return "GOTIFY_URL is not set. Skipping notification."
try:
payload = {"title": title, "message": message}
response = requests.post(GOTIFY_URL, json=payload)
response.raise_for_status()
return "Gotify notification sent successfully."
except requests.exceptions.RequestException as e:
print(f"Error sending Gotify notification: {e}")
return "Error sending Gotify notification."
from translations import get_standard_items, get_all_translations
# --- Datenbank-Setup ---
def column_exists(cursor, table_name, column_name):
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [info[1] for info in cursor.fetchall()]
return column_name in columns
def init_db():
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT 0
)
""")
if not column_exists(cursor, "users", "failed_login_attempts"):
cursor.execute("ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0")
if not column_exists(cursor, "users", "is_locked"):
cursor.execute("ALTER TABLE users ADD COLUMN is_locked BOOLEAN NOT NULL DEFAULT 0")
if not column_exists(cursor, "users", "locked_at"):
cursor.execute("ALTER TABLE users ADD COLUMN locked_at TIMESTAMP")
cursor.execute("""
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
is_standard BOOLEAN NOT NULL DEFAULT 0
)
""")
if not column_exists(cursor, "items", "created_by_user_id"):
cursor.execute("ALTER TABLE items ADD COLUMN created_by_user_id INTEGER REFERENCES users(id)")
cursor.execute("SELECT COUNT(*) FROM users")
if cursor.fetchone()[0] == 0:
admin_password = "admin"
hashed_password = pwd_context.hash(admin_password)
cursor.execute(
"INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)",
("admin", hashed_password, True)
)
print("Default admin user 'admin' with password 'admin' created.")
print("Please change this password in a production environment.")
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)
@app.get("/api/translations/{lang}")
async def get_translations_route(lang: str):
return get_all_translations(lang)
# --- API Endpunkte ---
@app.post("/token", response_model=Token)
async def login_for_access_token(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
client_ip = request.client.host
now = datetime.utcnow()
ip_failures = failed_login_attempts_ip[client_ip]
if ip_failures['count'] >= 15 and (now - ip_failures['timestamp']) < timedelta(minutes=LOCKOUT_PERIOD_MINUTES):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many failed login attempts from this IP. Please try again later."
)
user = get_user(form_data.username)
if user and user.is_locked:
lockout_expiry = user.locked_at + timedelta(minutes=LOCKOUT_PERIOD_MINUTES) if user.locked_at else now
if now < lockout_expiry:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is locked due to too many failed login attempts."
)
else:
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("UPDATE users SET is_locked = 0, failed_login_attempts = 0, locked_at = NULL WHERE username = ?",
(user.username,))
conn.commit()
conn.close()
user = get_user(form_data.username)
if not user or not verify_password(form_data.password, user.password_hash):
if user:
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
new_attempts = user.failed_login_attempts + 1
max_attempts = MAX_FAILED_ATTEMPTS_ADMIN if user.is_admin else MAX_FAILED_ATTEMPTS_USER
if new_attempts >= max_attempts:
cursor.execute("UPDATE users SET failed_login_attempts = ?, is_locked = 1, locked_at = ? WHERE username = ?",
(new_attempts, now, user.username))
else:
cursor.execute("UPDATE users SET failed_login_attempts = ? WHERE username = ?", (new_attempts, user.username))
conn.commit()
conn.close()
ip_failures['count'] += 1
ip_failures['timestamp'] = now
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
if user.failed_login_attempts > 0 or user.is_locked:
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("UPDATE users SET failed_login_attempts = 0, is_locked = 0, locked_at = NULL WHERE username = ?",
(user.username,))
conn.commit()
conn.close()
failed_login_attempts_ip[client_ip] = {'count': 0, 'timestamp': now}
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@app.get("/api/users/me", response_model=User)
async def read_users_me(current_user: User = Depends(get_current_active_user)):
return current_user
@app.post("/api/users", status_code=status.HTTP_201_CREATED)
async def create_user(user: NewUser, current_user: User = Depends(get_current_active_user)):
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can create users")
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("SELECT id FROM users WHERE username = ?", (user.username,))
if cursor.fetchone():
conn.close()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already registered")
hashed_password = pwd_context.hash(user.password)
cursor.execute(
"INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)",
(user.username, hashed_password, user.is_admin)
)
conn.commit()
conn.close()
return {"status": f"User {user.username} created"}
class PasswordChangeRequest(BaseModel):
current_password: str
new_password: str
@app.put("/api/users/me/password", status_code=status.HTTP_200_OK)
async def change_password(
request: PasswordChangeRequest,
current_user: UserInDB = Depends(get_current_active_user)
):
if not verify_password(request.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect current password",
)
new_hashed_password = pwd_context.hash(request.new_password)
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
(new_hashed_password, current_user.id)
)
conn.commit()
conn.close()
return {"status": "Password updated successfully"}
class UserListUser(BaseModel):
id: int
username: str
is_admin: bool
is_locked: bool
@app.get("/api/users", response_model=List[UserListUser])
async def get_users(current_user: UserInDB = Depends(get_current_active_user)):
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can access the user list")
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT id, username, is_admin, is_locked FROM users")
users = cursor.fetchall()
conn.close()
return [dict(row) for row in users]
@app.delete("/api/users/{user_id}", status_code=status.HTTP_200_OK)
async def delete_user(
user_id: int,
current_user: UserInDB = Depends(get_current_active_user)
):
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can delete users")
if current_user.id == user_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Admins cannot delete themselves")
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("SELECT id FROM users WHERE id = ?", (user_id,))
if cursor.fetchone() is None:
conn.close()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
conn.close()
return {"status": "User deleted successfully"}
@app.get("/api/users/locked", response_model=List[UserListUser])
async def get_locked_users(current_user: UserInDB = Depends(get_current_active_user)):
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can access this resource")
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT id, username, is_admin, is_locked FROM users WHERE is_locked = 1")
locked_users = cursor.fetchall()
conn.close()
return [dict(row) for row in locked_users]
@app.put("/api/users/{user_id}/unlock", status_code=status.HTTP_200_OK)
async def unlock_user(user_id: int, current_user: UserInDB = Depends(get_current_active_user)):
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can perform this action")
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("UPDATE users SET is_locked = 0, failed_login_attempts = 0, locked_at = NULL WHERE id = ?", (user_id,))
conn.commit()
conn.close()
return {"status": "User unlocked successfully"}
@app.get("/api/items")
async def get_items(current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT id, name, is_standard, created_by_user_id 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, current_user: UserInDB = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
added_count = 0
for name in item.names:
cursor.execute("SELECT * FROM items WHERE name = ?", (name,))
if cursor.fetchone():
continue
standard_items_de = get_standard_items('de')
standard_items_en = get_standard_items('en')
is_standard = name in standard_items_de or name in standard_items_en
try:
cursor.execute("INSERT INTO items (name, is_standard, created_by_user_id) VALUES (?, ?, ?)",
(name, is_standard, current_user.id))
added_count += 1
except sqlite3.IntegrityError:
continue
conn.commit()
conn.close()
if added_count > 0:
return {"status": "ok", "added": added_count}
else:
return {"status": "exists"}
@app.delete("/api/items/{item_id}")
async def delete_item(item_id: int, current_user: User = Depends(get_current_active_user)):
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can delete items")
conn = sqlite3.connect(DB_FILE)
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(lang: str = 'en', current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT * FROM items ORDER BY name")
items = cursor.fetchall()
conn.close()
item_names = [dict(row)["name"] for row in items]
translations = get_all_translations(lang)
if not item_names:
message = translations.get("empty_list_message", "The shopping list is empty.")
else:
message = ", ".join(item_names)
status = send_gotify_notification(
translations.get("notification_title", "Shopping List Updated"),
message
)
return {"status": status}
@app.get("/api/db-status")
async def get_db_status(current_user: User = Depends(get_current_active_user)):
try:
conn = sqlite3.connect(DB_FILE)
version = sqlite3.sqlite_version
conn.close()
return {"version": version, "error": None}
except sqlite3.Error as e:
return {"version": None, "error": str(e)}
@app.websocket("/ws/{token}")
async def websocket_endpoint(websocket: WebSocket, token: str):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
user = get_user(username=username)
if user is None:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
except JWTError:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
await manager.connect(websocket, user)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
await manager.broadcast_user_list()
# --- Statische Dateien (Frontend) ---
# Create data directory if it doesn't exist
os.makedirs(os.path.dirname(DB_FILE), exist_ok=True)
init_db()
app.mount("/", StaticFiles(directory="static", html=True), name="static")

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi
uvicorn[standard]
requests
passlib[bcrypt]==1.7.4
bcrypt==3.2.0
python-jose[cryptography]
python-multipart

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

806
static/index.html Normal file
View File

@@ -0,0 +1,806 @@
<!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>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="icon" type="image/png" href="/favicon.png">
<style>
body {
background: linear-gradient(to right, #ece9e6, #ffffff);
}
.container {
max-width: 800px;
}
.input-card-sticky {
position: -webkit-sticky;
position: sticky;
top: 0;
z-index: 1020;
}
#page-title {
font-weight: 300;
}
.list-group-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.delete-btn {
cursor: pointer;
color: #dc3545;
font-weight: bold;
}
#status-bar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background-color: #f8f9fa;
padding: 5px;
text-align: center;
font-size: 12px;
border-top: 1px solid #dee2e6;
}
#suggestion-info {
font-size: 0.9rem;
color: #6c757d;
margin-top: 10px;
}
#suggestion-box {
margin-top: 5px;
}
.suggestion-btn {
margin-right: 5px;
margin-bottom: 5px;
}
#login-view, #app-view {
display: none; /* Hidden by default */
}
#login-view, #app-view {
display: none; /* Hidden by default */
}
</style> </style>
<body>
<!-- Login View -->
<div id="login-view" class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-body">
<h3 id="login-title" class="card-title text-center"></h3>
<form id="login-form">
<div class="mb-3">
<label id="login-username-label" for="username" class="form-label"></label>
<input type="text" class="form-control" id="username" required>
</div>
<div class="mb-3">
<label id="login-password-label" for="password" class="form-label"></label>
<input type="password" class="form-control" id="password" required>
</div>
<div class="d-grid">
<button id="login-button" type="submit" class="btn btn-primary"></button>
</div>
<div id="login-error" class="text-danger mt-2"></div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Main App View -->
<div id="app-view" class="container mt-5">
<div class="d-flex justify-content-between align-items-center">
<h1 id="page-title" class="text-center mb-4"></h1>
<button id="logout-btn" class="btn btn-sm btn-outline-secondary">Logout</button>
</div>
<div id="online-users-container" class="mb-3" style="display: none;">
<strong>Online:</strong> <span id="online-users-list"></span>
</div>
<div id="user-view-content">
<div class="card shadow-sm mb-4 input-card-sticky">
<div class="card-body">
<form id="add-item-form" class="input-group">
<input type="text" id="item-name" class="form-control" autocomplete="off" required>
<button type="submit" id="add-button" class="btn btn-primary"></button>
<button type="button" id="add-one-button" class="btn btn-secondary"></button>
</form>
<div id="suggestion-info">
<div class="d-flex align-items-center mb-1">
<button id="example-button-existing" type="button" class="btn btn-primary btn-sm me-2" disabled></button>
<span id="info-text-existing"></span>
</div>
<div class="d-flex align-items-center">
<button id="example-button-new" type="button" class="btn btn-outline-primary btn-sm me-2" disabled></button>
<span id="info-text-new"></span>
</div>
</div>
<div id="suggestion-box"></div>
</div>
</div>
<div class="d-grid gap-2 mb-3">
<button id="notify-btn" class="btn btn-success"></button>
</div>
<ul id="item-list" class="list-group shadow-sm"></ul>
</div>
<!-- Admin Section -->
<div id="admin-section" class="card shadow-sm mt-5" style="display: none;">
<div id="admin-panel-title" class="card-header"></div>
<div class="card-body">
<h5 id="create-user-title" class="card-title"></h5>
<form id="create-user-form">
<div class="row">
<div class="col-md-4 mb-3">
<label id="username-label" for="new-username" class="form-label"></label>
<input type="text" id="new-username" class="form-control" required>
</div>
<div class="col-md-4 mb-3">
<label id="password-label" for="new-password" class="form-label"></label>
<input type="password" id="new-password" class="form-control" required>
</div>
<div class="col-md-4 mb-3 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="new-user-is-admin">
<label id="is-admin-label" class="form-check-label" for="new-user-is-admin"></label>
</div>
</div>
</div>
<button id="create-user-button" type="submit" class="btn btn-success"></button>
<div id="create-user-status" class="mt-2"></div>
</form>
<hr class="my-4">
<h5 id="change-password-title" class="card-title"></h5>
<form id="change-password-form">
<div class="row">
<div class="col-md-4 mb-3">
<label id="current-password-label" for="current-password" class="form-label"></label>
<input type="password" id="current-password" class="form-control" required>
</div>
<div class="col-md-4 mb-3">
<label id="new-password-label" for="new-password-change" class="form-label"></label>
<input type="password" id="new-password-change" class="form-control" required>
</div>
<div class="col-md-4 mb-3">
<label id="confirm-password-label" for="confirm-password" class="form-label"></label>
<input type="password" id="confirm-password" class="form-control" required>
</div>
</div>
<button id="change-password-button" type="submit" class="btn btn-primary"></button>
<div id="change-password-status" class="mt-2"></div>
</form>
<hr class="my-4">
<h5 id="manage-users-title" class="card-title"></h5>
<ul id="user-list" class="list-group mb-3">
<!-- Users will be populated by JavaScript -->
</ul>
</div>
</div>
</div>
<div id="status-bar"></div>
<script>
// Views
const loginView = document.getElementById('login-view');
const appView = document.getElementById('app-view');
const userViewContent = document.getElementById('user-view-content');
// Login elements
const loginForm = document.getElementById('login-form');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const loginError = document.getElementById('login-error');
const logoutBtn = document.getElementById('logout-btn');
const adminSection = document.getElementById('admin-section');
const createUserForm = document.getElementById('create-user-form');
const newUsernameInput = document.getElementById('new-username');
const newPasswordInput = document.getElementById('new-password');
const newUserIsAdminCheckbox = document.getElementById('new-user-is-admin');
const createUserStatus = document.getElementById('create-user-status');
// App elements
const pageTitle = document.getElementById('page-title');
const itemList = document.getElementById('item-list');
const addItemForm = document.getElementById('add-item-form');
const itemNameInput = document.getElementById('item-name');
const addButton = document.getElementById('add-button');
const addOneButton = document.getElementById('add-one-button');
const suggestionInfo = document.getElementById('suggestion-info');
const suggestionBox = document.getElementById('suggestion-box');
const notifyBtn = document.getElementById('notify-btn');
const exampleButtonExisting = document.getElementById('example-button-existing');
const infoTextExisting = document.getElementById('info-text-existing');
const exampleButtonNew = document.getElementById('example-button-new');
const infoTextNew = document.getElementById('info-text-new');
const statusBar = document.getElementById('status-bar');
const onlineUsersContainer = document.getElementById('online-users-container');
const onlineUsersList = document.getElementById('online-users-list');
let standardItems = [];
let translations = {};
let currentShoppingList = [];
let currentUser = {};
let socket;
// --- WebSocket Functions ---
function connectWebSocket() {
const token = localStorage.getItem('access_token');
if (!token) return;
// Close existing socket if any
if (socket && socket.readyState === WebSocket.OPEN) {
socket.close();
}
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
socket = new WebSocket(`${wsProtocol}://${window.location.host}/ws/${token}`);
socket.onopen = () => {
console.log("WebSocket connection established.");
};
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'user_list') {
if (data.users && data.users.length > 1) {
onlineUsersList.textContent = data.users.join(', ');
onlineUsersContainer.style.display = 'block';
} else {
onlineUsersContainer.style.display = 'none';
}
}
};
socket.onclose = () => {
console.log("WebSocket connection closed. Attempting to reconnect...");
setTimeout(connectWebSocket, 5000); // Reconnect after 5 seconds
};
socket.onerror = (error) => {
console.error("WebSocket error:", error);
socket.close();
};
}
// --- Auth Functions ---
function getAuthHeaders() {
const token = localStorage.getItem('access_token');
return {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
};
}
async function handleLogin(event) {
event.preventDefault();
loginError.textContent = '';
const formData = new FormData();
formData.append('username', usernameInput.value);
formData.append('password', passwordInput.value);
try {
const response = await fetch('/token', {
method: 'POST',
body: new URLSearchParams(formData)
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('access_token', data.access_token);
await showAppView();
} else {
loginError.textContent = translations.login_error_incorrect || 'Incorrect username or password.';
}
} catch (error) {
loginError.textContent = translations.login_error_generic || 'An error occurred. Please try again.';
}
}
function handleLogout() {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.close();
}
localStorage.removeItem('access_token');
location.reload();
}
async function handleCreateUser(event) {
event.preventDefault();
createUserStatus.textContent = '';
const newUser = {
username: newUsernameInput.value,
password: newPasswordInput.value,
is_admin: newUserIsAdminCheckbox.checked
};
const response = await fetch('/api/users', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(newUser)
});
if (response.status === 401) return handleLogout();
const data = await response.json();
if (response.ok) {
const successMessage = (translations.user_created_success || "User '{username}' created successfully.").replace('{username}', newUser.username);
createUserStatus.textContent = successMessage;
createUserStatus.className = 'mt-2 text-success';
createUserForm.reset();
await fetchAndRenderUsers(); // Refresh the user list
} else {
createUserStatus.textContent = `Error: ${data.detail}`;
createUserStatus.className = 'mt-2 text-danger';
}
}
// --- View Management ---
function showLoginView() {
appView.style.display = 'none';
loginView.style.display = 'block';
adminSection.style.display = 'none';
if (userViewContent) userViewContent.style.display = 'none';
}
async function showAppView() {
loginView.style.display = 'none';
appView.style.display = 'block';
adminSection.style.display = 'none';
if (userViewContent) userViewContent.style.display = 'none';
connectWebSocket();
await initApp();
}
// --- App Functions ---
async function fetchCurrentUser() {
const response = await fetch(`/api/users/me?_=${new Date().getTime()}`, { headers: getAuthHeaders() });
if (response.status === 401) return handleLogout();
currentUser = await response.json();
if (currentUser.is_admin) {
adminSection.style.display = 'block';
userViewContent.style.display = 'none';
pageTitle.textContent = 'Admin Panel';
} else {
adminSection.style.display = 'none';
userViewContent.style.display = 'block';
// Title will be set by fetchTranslations for regular users
}
}
async function fetchTranslations() {
const lang = navigator.language.startsWith('de') ? 'de' : 'en';
const response = await fetch(`/api/translations/${lang}?_=${new Date().getTime()}`);
translations = await response.json();
updateUIWithTranslations();
}
function updateUIWithTranslations() {
document.title = translations.title;
// Login View
if(document.getElementById('login-title')) {
document.getElementById('login-title').textContent = translations.login_title;
document.getElementById('login-username-label').textContent = translations.login_username_label;
document.getElementById('login-password-label').textContent = translations.login_password_label;
document.getElementById('login-button').textContent = translations.login_button;
}
// User View
pageTitle.textContent = translations.title;
itemNameInput.placeholder = translations.placeholder;
addButton.textContent = translations.add_button;
addOneButton.textContent = translations.add_one_button;
notifyBtn.textContent = translations.notify_button;
exampleButtonExisting.textContent = translations.example_button_text || 'Example';
exampleButtonNew.textContent = translations.example_button_text || 'Example';
infoTextExisting.textContent = translations.info_text_existing || '';
infoTextNew.textContent = translations.info_text_new || '';
// Admin View
if (document.getElementById('admin-panel-title')) {
document.getElementById('admin-panel-title').textContent = translations.admin_panel_title;
document.getElementById('create-user-title').textContent = translations.create_user_title;
document.getElementById('username-label').textContent = translations.username_label;
document.getElementById('password-label').textContent = translations.password_label;
document.getElementById('is-admin-label').textContent = translations.is_admin_label;
document.getElementById('create-user-button').textContent = translations.create_user_button;
document.getElementById('change-password-title').textContent = translations.change_password_title;
document.getElementById('current-password-label').textContent = translations.current_password_label;
document.getElementById('new-password-label').textContent = translations.new_password_label;
document.getElementById('confirm-password-label').textContent = translations.confirm_password_label;
document.getElementById('change-password-button').textContent = translations.change_password_button;
document.getElementById('manage-users-title').textContent = translations.manage_users_title;
}
// Common elements
if (document.getElementById('logout-btn')) {
document.getElementById('logout-btn').textContent = translations.logout_button;
}
}
async function fetchStandardItems() {
const lang = navigator.language.startsWith('de') ? 'de' : 'en';
const response = await fetch(`/api/standard-items/${lang}`);
standardItems = await response.json();
}
async function fetchItems() {
const response = await fetch('/api/items', { headers: getAuthHeaders() });
if (response.status === 401) return handleLogout();
const data = await response.json();
currentShoppingList = data.items;
itemList.innerHTML = '';
data.items.forEach(item => {
const li = document.createElement('li');
li.className = 'list-group-item';
li.textContent = item.name;
if (currentUser.is_admin) {
const deleteBtn = document.createElement('span');
deleteBtn.textContent = 'X';
deleteBtn.className = 'delete-btn';
deleteBtn.onclick = () => deleteItem(item.id);
li.appendChild(deleteBtn);
}
itemList.appendChild(li);
});
handleInputChange();
}
async function addItemsFromInput(inputString, split) {
if (!inputString.trim()) return;
let itemsToAdd = [];
if (split) {
itemsToAdd = inputString.split(/[ ,]+/).map(item => item.trim()).filter(item => item);
} else {
itemsToAdd.push(inputString.trim());
}
if (itemsToAdd.length === 0) return;
const response = await fetch('/api/items', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ names: itemsToAdd })
});
if (response.status === 401) return handleLogout();
const result = await response.json();
if (result.status === 'exists' && result.added === 0) {
console.log('All items already exist.');
}
await fetchItems();
handleInputChange();
}
async function deleteItem(id) {
const response = await fetch(`/api/items/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (response.status === 401) return handleLogout();
if (response.status === 403) {
alert("Only admins can delete items."); // Simple feedback for non-admins
return;
}
await fetchItems();
}
function handleInputChange() {
const value = itemNameInput.value.trim().toLowerCase();
const currentShoppingListNames = currentShoppingList.map(item => item.name.toLowerCase());
if (value === '') {
suggestionBox.innerHTML = '';
suggestionInfo.style.display = 'none';
return;
}
if (suggestionInfo) suggestionInfo.style.display = 'block';
const searchTerms = value.split(/[ ,]+/).map(term => term.trim()).filter(term => term);
const lastTerm = searchTerms[searchTerms.length - 1];
if (!lastTerm) {
suggestionBox.innerHTML = '';
return;
}
const newSuggestions = standardItems.filter(item =>
item.toLowerCase().includes(lastTerm) &&
!currentShoppingListNames.includes(item.toLowerCase())
);
const existingItems = currentShoppingList
.filter(item => item.name.toLowerCase().includes(lastTerm))
.map(item => item.name);
const existingStandardSuggestions = standardItems.filter(item =>
item.toLowerCase().includes(lastTerm) &&
currentShoppingListNames.includes(item.toLowerCase())
);
const allExistingItems = [...new Set([...existingItems, ...existingStandardSuggestions])];
displaySuggestions(allExistingItems, newSuggestions);
}
function displaySuggestions(existing, news) {
suggestionBox.innerHTML = '';
existing.slice(0, 10).forEach(suggestion => {
const btn = createSuggestionButton(suggestion, true);
suggestionBox.appendChild(btn);
});
news.slice(0, 10).forEach(suggestion => {
const btn = createSuggestionButton(suggestion, false);
suggestionBox.appendChild(btn);
});
}
function createSuggestionButton(suggestion, isExisting) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = isExisting
? 'btn btn-primary btn-sm suggestion-btn'
: 'btn btn-outline-primary btn-sm suggestion-btn';
btn.textContent = suggestion;
btn.disabled = isExisting;
if (!isExisting) {
btn.onclick = () => onSuggestionClick(suggestion);
}
return btn;
}
async function onSuggestionClick(suggestion) {
const value = itemNameInput.value;
await addItemsFromInput(suggestion, false);
const lastSeparatorIndex = Math.max(value.lastIndexOf(','), value.lastIndexOf(' '));
let newValue = '';
if (lastSeparatorIndex !== -1) {
newValue = value.substring(0, lastSeparatorIndex + 1);
}
itemNameInput.value = newValue;
suggestionBox.innerHTML = '';
itemNameInput.focus();
}
async function sendNotification() {
const lang = navigator.language.startsWith('de') ? 'de' : 'en';
notifyBtn.disabled = true;
notifyBtn.textContent = translations.sending_notification || 'Sending...';
try {
const response = await fetch(`/api/notify?lang=${lang}`, {
method: 'POST',
headers: getAuthHeaders()
});
if (response.status === 401) return handleLogout();
const data = await response.json();
if (response.ok && data.status.includes("success")) {
notifyBtn.textContent = translations.notification_sent || 'Notification sent!';
} else {
notifyBtn.textContent = translations.generic_notification_error || 'Error sending notification.';
}
} catch (error) {
notifyBtn.textContent = translations.generic_notification_error || 'Error sending notification.';
}
setTimeout(() => {
notifyBtn.disabled = false;
notifyBtn.textContent = translations.notify_button || 'Finalize Changes & Notify';
}, 3000);
}
async function fetchDbStatus() {
try {
const response = await fetch('/api/db-status', { headers: getAuthHeaders() });
if (response.status === 401) return handleLogout();
const data = await response.json();
if (data.error) {
statusBar.textContent = (translations.db_error || "Database error: {error}").replace("{error}", data.error);
} else {
statusBar.textContent = (translations.db_version || "Database version: {version}").replace("{version}", data.version);
}
} catch (error) {
statusBar.textContent = (translations.db_error || "Database error: {error}").replace("{error}", "Connection failed");
}
}
// --- Event Listeners ---
loginForm.addEventListener('submit', handleLogin);
logoutBtn.addEventListener('click', handleLogout);
createUserForm.addEventListener('submit', handleCreateUser);
addItemForm.addEventListener('submit', async (event) => {
event.preventDefault();
await addItemsFromInput(itemNameInput.value, true);
itemNameInput.value = '';
suggestionBox.innerHTML = '';
});
addOneButton.addEventListener('click', async () => {
await addItemsFromInput(itemNameInput.value, false);
itemNameInput.value = '';
suggestionBox.innerHTML = '';
});
itemNameInput.addEventListener('input', handleInputChange);
notifyBtn.addEventListener('click', sendNotification);
// --- Initialisierung ---
async function initApp() {
// NOTE: Translations are now fetched before this function is called.
const promises = [
fetchCurrentUser(),
fetchStandardItems(),
fetchItems(),
fetchAndRenderUsers(),
fetchDbStatus()
];
const results = await Promise.allSettled(promises);
results.forEach((result, i) => {
if (result.status === 'rejected') {
console.error(`Error in initApp data fetch step ${i}:`, result.reason);
}
});
if (suggestionInfo) suggestionInfo.style.display = 'none';
}
// --- User Management Logic ---
const userList = document.getElementById('user-list');
async function unlockUser(userId, username) {
// TODO: Add translation for the confirmation message
if (!confirm(`Are you sure you want to unlock the user "${username}"?`)) {
return;
}
const response = await fetch(`/api/users/${userId}/unlock`, {
method: 'PUT',
headers: getAuthHeaders()
});
if (response.status === 401) return handleLogout();
if (response.ok) {
await fetchAndRenderUsers();
} else {
const data = await response.json();
alert(`Error: ${data.detail}`);
}
}
async function deleteUser(userId, username) {
if (!confirm(`Are you sure you want to delete the user "${username}"?`)) {
return;
}
const response = await fetch(`/api/users/${userId}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (response.status === 401) return handleLogout();
if (response.ok) {
await fetchAndRenderUsers();
} else {
const data = await response.json();
alert(`Error: ${data.detail}`);
}
}
async function fetchAndRenderUsers() {
if (!userList) return;
const response = await fetch('/api/users', { headers: getAuthHeaders() });
if (response.status === 401) return handleLogout();
const users = await response.json();
userList.innerHTML = '';
users.forEach(user => {
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center';
const userInfo = document.createElement('span');
userInfo.textContent = `${user.username}`;
if (user.is_admin) {
const adminBadge = document.createElement('span');
adminBadge.className = 'badge bg-primary rounded-pill ms-2';
adminBadge.textContent = 'Admin';
userInfo.appendChild(adminBadge);
}
if (user.is_locked) {
const lockedBadge = document.createElement('span');
// TODO: Add translation for "Locked"
lockedBadge.className = 'badge bg-warning text-dark rounded-pill ms-2';
lockedBadge.textContent = 'Locked';
userInfo.appendChild(lockedBadge);
}
li.appendChild(userInfo);
const buttonContainer = document.createElement('div');
if (currentUser && currentUser.id !== user.id) {
if (user.is_locked) {
const unlockBtn = document.createElement('button');
unlockBtn.className = 'btn btn-success btn-sm';
// TODO: Add translation for "Unlock"
unlockBtn.textContent = 'Unlock';
unlockBtn.onclick = () => unlockUser(user.id, user.username);
buttonContainer.appendChild(unlockBtn);
} else {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-danger btn-sm ms-2';
deleteBtn.textContent = translations.delete_button || 'Delete';
deleteBtn.onclick = () => deleteUser(user.id, user.username);
buttonContainer.appendChild(deleteBtn);
}
}
li.appendChild(buttonContainer);
userList.appendChild(li);
});
}
// --- Change Password Logic ---
// --- Main Application Entry Point ---
async function main() {
// Always fetch translations first, so all UI is translated before showing.
await fetchTranslations();
// Now decide which view to show
if (localStorage.getItem('access_token')) {
showAppView();
} else {
showLoginView();
}
}
main(); // Run the app
</script>
</body>
</html>

107
translations.py Normal file
View File

@@ -0,0 +1,107 @@
import xml.etree.ElementTree as ET
from typing import List, Dict
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 = "translations/de/strings.xml"
else:
filepath = "translations/en/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 []
def get_all_translations(lang: str) -> Dict[str, str]:
"""
Returns a dictionary with all UI and notification translations for the web app.
"""
if lang == "de":
return {
"title": "Geteilte Einkaufsliste",
"placeholder": "Artikel eingeben...",
"add_button": "Hinzufügen",
"add_one_button": "+1",
"notify_button": "Änderungen abschließen & Benachrichtigen",
"sending_notification": "Sende...",
"notification_sent": "Benachrichtigung gesendet!",
"notification_title": "Einkaufsliste aktualisiert",
"empty_list_message": "Die Einkaufsliste ist leer.",
"db_version": "Datenbankversion: {version}",
"db_error": "Datenbankfehler: {error}",
"generic_notification_error": "Fehler beim Senden der Benachrichtigung.",
"example_button_text": "Beispiel",
"info_text_existing": "= ist schon in der Liste",
"info_text_new": "= noch nicht in der Liste",
"admin_panel_title": "Admin Panel",
"create_user_title": "Neuen Benutzer erstellen",
"username_label": "Benutzername",
"password_label": "Passwort",
"is_admin_label": "Ist Admin?",
"create_user_button": "Benutzer erstellen",
"change_password_title": "Passwort ändern",
"current_password_label": "Aktuelles Passwort",
"new_password_label": "Neues Passwort",
"confirm_password_label": "Neues Passwort bestätigen",
"change_password_button": "Passwort ändern",
"manage_users_title": "Benutzer verwalten",
"delete_button": "Löschen",
"logout_button": "Abmelden",
"login_title": "Anmelden",
"login_username_label": "Benutzername",
"login_password_label": "Passwort",
"login_button": "Anmelden",
"user_created_success": "Benutzer '{username}' erfolgreich erstellt.",
"login_error_incorrect": "Benutzername oder Passwort inkorrekt.",
"login_error_generic": "Ein Fehler ist aufgetreten. Bitte erneut versuchen."
}
else: # Fallback to English
return {
"title": "Shared Shopping List",
"placeholder": "Enter item...",
"add_button": "Add",
"add_one_button": "+1",
"notify_button": "Finalize Changes & Notify",
"sending_notification": "Sending...",
"notification_sent": "Notification sent!",
"notification_title": "Shopping List Updated",
"empty_list_message": "The shopping list is empty.",
"db_version": "Database version: {version}",
"db_error": "Database error: {error}",
"generic_notification_error": "Error sending notification.",
"example_button_text": "Example",
"info_text_existing": "= is already on the list",
"info_text_new": "= not yet on the list",
"admin_panel_title": "Admin Panel",
"create_user_title": "Create New User",
"username_label": "Username",
"password_label": "Password",
"is_admin_label": "Is Admin?",
"create_user_button": "Create User",
"change_password_title": "Change Your Password",
"current_password_label": "Current Password",
"new_password_label": "New Password",
"confirm_password_label": "Confirm New Password",
"change_password_button": "Change Password",
"manage_users_title": "Manage Users",
"delete_button": "Delete",
"logout_button": "Logout",
"login_title": "Login",
"login_username_label": "Username",
"login_password_label": "Password",
"login_button": "Login",
"user_created_success": "User '{username}' created successfully.",
"login_error_incorrect": "Incorrect username or password.",
"login_error_generic": "An error occurred. Please try again."
}

111
translations/de/strings.xml Normal file
View File

@@ -0,0 +1,111 @@
<resources>
<string-array name="standard_list_items">
<item>Äpfel</item>
<item>Antikalk</item>
<item>Apfelmus</item>
<item>Asia Nudeln</item>
<item>Backofen Fisch</item>
<item>Backpapier</item>
<item>Backpulver</item>
<item>Baguette</item>
<item>Bananen</item>
<item>Batterien</item>
<item>Bierschinken</item>
<item>Bockwurst</item>
<item>Bohnen</item>
<item>Bolognese Fix</item>
<item>Brokkoli</item>
<item>Brot</item>
<item>Brötchen</item>
<item>Butter</item>
<item>Cashewkerne</item>
<item>Chicken Wings</item>
<item>Chips</item>
<item>Cola</item>
<item>Cola Zero</item>
<item>Cornflakes</item>
<item>Curry Fix</item>
<item>Desinfektionsmittel</item>
<item>Eier</item>
<item>Eis</item>
<item>Eisberg Salat</item>
<item>Erbsen</item>
<item>Erbsen Wurzel Gemüse</item>
<item>Fanta</item>
<item>Fisch</item>
<item>Flips</item>
<item>Frituesen Fett</item>
<item>Ganze Mandeln</item>
<item>Gemüse</item>
<item>Gewürzgurken</item>
<item>Gyros</item>
<item>Haferflocken</item>
<item>Hafermilch</item>
<item>Hamburger</item>
<item>Hamburger Brötchen</item>
<item>Hamburger Fleisch</item>
<item>Haribo</item>
<item>Haselnüsse</item>
<item>Honig</item>
<item>Kaba</item>
<item>Kaffee</item>
<item>Karotten</item>
<item>Kartoffeln</item>
<item>Käse</item>
<item>Ketchup</item>
<item>Kidney Bohnen</item>
<item>Kirschen</item>
<item>Kiwi</item>
<item>Kochschinken</item>
<item>Kräutersalz</item>
<item>Küchen Tücher</item>
<item>Lactosefreie Milch</item>
<item>Lasangne Platten</item>
<item>Leberwurst</item>
<item>Lorbeer Blätter</item>
<item>Mayonnaise</item>
<item>Margarine</item>
<item>Mehl</item>
<item>Milch</item>
<item>Milchreis</item>
<item>Müllbeutel 60l</item>
<item>Nudeln</item>
<item>Nuggets</item>
<item>Olivenöl</item>
<item>Pfeffer</item>
<item>Pizza Baguette</item>
<item>Pommes</item>
<item>Pommes Salz</item>
<item>Putzmittel</item>
<item>Rapsöl</item>
<item>Red Bull</item>
<item>Reibekäse</item>
<item>Reis</item>
<item>Remoulade</item>
<item>Rotkohl</item>
<item>Sahnesteif</item>
<item>Salat</item>
<item>Salz</item>
<item>Schlagsahne</item>
<item>Schweinebraten</item>
<item>Schweinemett</item>
<item>Spezi</item>
<item>Sprite</item>
<item>Sprudel</item>
<item>Sprühsahne</item>
<item>Tafel Essig</item>
<item>Taschen Tücher</item>
<item>Vanillin Zucker</item>
<item>Walnüsse</item>
<item>Wattestäbchen</item>
<item>Wienerle</item>
<item>Wings</item>
<item>Wurst</item>
<item>Wurstsalat</item>
<item>Zatziki</item>
<item>Zitronentee</item>
<item>Zucker Eier</item>
<item>Zwiebelmett</item>
<item>Zwiebeln</item>
</string-array>
</resources>

111
translations/en/strings.xml Normal file
View File

@@ -0,0 +1,111 @@
<resources>
<string-array name="standard_list_items">
<item>Apples</item>
<item>Apple sauce</item>
<item>Asian noodles</item>
<item>Baguette</item>
<item>Baking paper</item>
<item>Baking powder</item>
<item>Bananas</item>
<item>Batteries</item>
<item>Bay leaves</item>
<item>Beans</item>
<item>Beer ham</item>
<item>Bockwurst</item>
<item>Bolognese seasoning mix</item>
<item>Bread</item>
<item>Bread rolls</item>
<item>Broccoli</item>
<item>Butter</item>
<item>Canned whipped cream</item>
<item>Canola oil</item>
<item>Carrots</item>
<item>Cashew nuts</item>
<item>Cheese</item>
<item>Cherries</item>
<item>Chicken wings</item>
<item>Chip salt</item>
<item>Chips</item>
<item>Chocolate milk powder</item>
<item>Cleaning agent</item>
<item>Cola</item>
<item>Cola Zero</item>
<item>Cooked ham</item>
<item>Cornflakes</item>
<item>Cotton swabs</item>
<item>Cream stabilizer</item>
<item>Curry seasoning mix</item>
<item>Deep-frying fat</item>
<item>Descaler</item>
<item>Disinfectant</item>
<item>Eggs</item>
<item>Fanta</item>
<item>Fish</item>
<item>Flour</item>
<item>French fries</item>
<item>Frying fat</item>
<item>Garbage bags (60l)</item>
<item>Grated cheese</item>
<item>Gyros</item>
<item>Haribo</item>
<item>Hazelnuts</item>
<item>Herbal salt</item>
<item>Honey</item>
<item>Ice cream</item>
<item>Iceberg lettuce</item>
<item>Ketchup</item>
<item>Kidney beans</item>
<item>Kitchen towels</item>
<item>Kiwi</item>
<item>Lactose-free milk</item>
<item>Lasagna sheets</item>
<item>Lemon tea</item>
<item>Liver sausage</item>
<item>Mayonnaise</item>
<item>Milk</item>
<item>Minced pork (seasoned)</item>
<item>Noodles</item>
<item>Nuggets</item>
<item>Oat flakes</item>
<item>Oat milk</item>
<item>Olive oil</item>
<item>Onions</item>
<item>Oven fish</item>
<item>Paper towels</item>
<item>Pasta</item>
<item>Peanut flips</item>
<item>Peas</item>
<item>Peas and root vegetables</item>
<item>Pepper</item>
<item>Pickled gherkins</item>
<item>Pizza baguette</item>
<item>Pork roast</item>
<item>Potatoes</item>
<item>Rapeseed oil</item>
<item>Red Bull</item>
<item>Red cabbage</item>
<item>Remoulade sauce</item>
<item>Rice</item>
<item>Rice pudding</item>
<item>Rolled oats</item>
<item>Rolls</item>
<item>Salad</item>
<item>Salt</item>
<item>Sausage</item>
<item>Sausage salad</item>
<item>Seasoned minced pork with onions</item>
<item>Spezi</item>
<item>Sprite</item>
<item>Sparkling water</item>
<item>Sugar eggs</item>
<item>Table vinegar</item>
<item>Tissues</item>
<item>Tzatziki</item>
<item>Vanillin sugar</item>
<item>Vegetables</item>
<item>Vienna sausage</item>
<item>Walnuts</item>
<item>Whipped cream (liquid)</item>
<item>Whole almonds</item>
</string-array>
</resources>