Files
noteshop-webapp/static/index.html
Désiré Werner Menrath a7d88be89c feat(realtime): Implement real-time updates for shopping list
Introduces WebSocket-based real-time updates for the shopping list.
Changes to items (add, mark, delete) are now instantly reflected
across all connected user sessions without requiring a page refresh.

This commit:
- Extends the WebSocket ConnectionManager to broadcast item updates.
- Modifies item manipulation endpoints (add, mark, delete) to trigger broadcasts.
- Updates the frontend to listen for update broadcasts and refresh the list.
- Updates README.md to reflect the new real-time update feature.
2025-11-05 13:42:07 +01:00

1235 lines
54 KiB
HTML

<!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">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<link rel="icon" type="image/png" href="/favicon.png">
<style>
:root {
--background-gradient-start: #ece9e6;
--background-gradient-end: #ffffff;
--text-color: #212529;
--card-background-color: #ffffff;
--card-shadow-color: rgba(0, 0, 0, 0.05);
--input-background-color: #ffffff;
--input-text-color: #212529;
--status-bar-background-color: #f8f9fa;
--status-bar-border-color: #dee2e6;
--list-group-item-background-color: #ffffff;
}
[data-theme="dark"] {
--background-gradient-start: #212529;
--background-gradient-end: #343a40;
--text-color: #f8f9fa;
--card-background-color: #343a40;
--card-shadow-color: rgba(255, 255, 255, 0.05);
--input-background-color: #495057;
--input-text-color: #f8f9fa;
--status-bar-background-color: #343a40;
--status-bar-border-color: #495057;
--list-group-item-background-color: #343a40;
}
[data-theme="dark"] .text-dark {
color: var(--text-color) !important;
}
[data-theme="dark"] .modal-content {
background-color: var(--card-background-color);
}
[data-theme="dark"] .form-label,
[data-theme="dark"] .card-title,
[data-theme="dark"] #create-user-status,
[data-theme="dark"] #change-password-status,
[data-theme="dark"] #set-deletion-password-status {
color: var(--text-color);
}
[data-theme="dark"] .btn-primary {
background-color: #0d6efd;
border-color: #0d6efd;
}
[data-theme="dark"] .btn-outline-primary {
color: #0d6efd;
border-color: #0d6efd;
}
[data-theme="dark"] .btn-outline-primary:hover {
background-color: #0d6efd;
color: #fff;
}
[data-theme="dark"] .btn-secondary {
background-color: #6c757d;
border-color: #6c757d;
}
[data-theme="dark"] .btn-outline-secondary {
color: #6c757d;
border-color: #6c757d;
}
[data-theme="dark"] .btn-outline-secondary:hover {
background-color: #6c757d;
color: #fff;
}
[data-theme="dark"] #admin-panel-title,
[data-theme="dark"] #logout-btn,
[data-theme="dark"] #item-name,
[data-theme="dark"] #info-text-existing,
[data-theme="dark"] #info-text-new {
color: var(--text-color) !important;
}
[data-theme="dark"] #is-admin-label {
color: var(--text-color) !important;
}
[data-theme="dark"] #item-name::placeholder,
[data-theme="dark"] #deletion-password::placeholder {
color: var(--text-color) !important;
opacity: 0.5;
}
[data-theme="dark"] .form-check-input {
background-color: var(--input-background-color);
border-color: var(--status-bar-border-color);
}
[data-theme="dark"] #password-set-message p {
color: var(--text-color);
}
[data-theme="dark"] hr {
border: 0;
border-top: 1px solid var(--status-bar-border-color);
opacity: 1;
}
[data-theme="dark"] #toggle-deletion-password svg {
fill: #fff;
}
body {
background: linear-gradient(to right, var(--background-gradient-start), var(--background-gradient-end));
color: var(--text-color);
}
.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;
background-color: var(--list-group-item-background-color);
color: var(--text-color);
}
.delete-btn {
cursor: pointer;
color: #dc3545;
font-weight: bold;
}
#status-bar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background-color: var(--status-bar-background-color);
padding: 5px;
text-align: center;
font-size: 12px;
border-top: 1px solid var(--status-bar-border-color);
}
#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 */
}
.card {
background-color: var(--card-background-color);
box-shadow: 0 0.125rem 0.25rem var(--card-shadow-color);
}
.form-control {
background-color: var(--input-background-color);
color: var(--input-text-color);
}
.form-control:focus {
background-color: var(--input-background-color);
color: var(--input-text-color);
}
</style>
</head>
<body class="pb-5">
<script>
(function() {
const theme = localStorage.getItem('theme');
if (theme) {
document.documentElement.setAttribute('data-theme', theme);
} else {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
}
})();
</script>
<!-- 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>
<div class="d-flex align-items-center">
<button id="delete-marked-btn" class="btn btn-sm btn-outline-danger me-2" style="display: none;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
<button id="theme-switcher" class="btn btn-sm btn-outline-secondary me-2">☀️</button>
<button id="logout-btn" class="btn btn-sm btn-outline-secondary">Logout</button>
</div>
</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="row g-2 mb-3">
<div class="col">
<div class="d-grid">
<button id="notify-btn" class="btn btn-success"></button>
</div>
</div>
<div class="col-auto">
<div class="d-grid">
<button id="select-all-btn" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check2-square" viewBox="0 0 16 16">
<path d="M3 14.5A1.5 1.5 0 0 1 1.5 13V3A1.5 1.5 0 0 1 3 1.5h8A1.5 1.5 0 0 1 12.5 3v1.5a.5.5 0 0 1-1 0V3a.5.5 0 0 0-.5-.5H3a.5.5 0 0 0-.5.5v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V10a.5.5 0 0 1 1 0v3a1.5 1.5 0 0 1-1.5 1.5H3z"/>
<path d="m8.354 10.354 7-7a.5.5 0 0 0-.708-.708L8 9.293 5.354 6.646a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0z"/>
</svg>
</button>
</div>
</div>
</div>
<ul id="item-list" class="list-group shadow-sm"></ul>
</div>
<!-- Admin Section -->
<div id="admin-section" class="card shadow-sm mt-3" 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>
<hr class="my-4">
<h5 id="set-deletion-password-title" class="card-title"></h5>
<div id="password-set-message" style="display: none;">
<p>Passwort ist gesetzt.</p>
<button id="change-password-btn" class="btn btn-primary">Passwort ändern</button>
<button id="delete-password-btn" class="btn btn-danger">Passwort löschen</button>
</div>
<form id="set-deletion-password-form">
<div class="row">
<div class="col-md-6 mb-3">
<div class="input-group">
<input type="password" id="deletion-password" class="form-control" required>
<button class="btn btn-outline-secondary" type="button" id="toggle-deletion-password">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye" viewBox="0 0 16 16">
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.12 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye-slash" viewBox="0 0 16 16" style="display: none;">
<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.94 5.94 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.707z"/>
<path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.288.822.822.073.073.026.026a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829l.822.822zm-2.943-1.288.822.822.073.073.026.026a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829l.822.822zm-2.943-1.288.822.822.073.073.026.026a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829l.822.822z"/>
<path d="M.5 8a.5.5 0 0 1 .5-.5h14a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z"/>
</svg>
</button>
</div>
</div>
</div>
<button id="set-deletion-password-button" type="submit" class="btn btn-primary"></button>
<div id="set-deletion-password-status" class="mt-2"></div>
</form>
</div>
</div>
</div>
<div id="status-bar"></div>
<!-- Deletion Password Modal -->
<div class="modal fade" id="delete-password-modal" tabindex="-1" aria-labelledby="delete-password-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="delete-password-modal-label"></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="delete-password-form">
<div class="mb-3">
<label id="delete-password-label" for="delete-password-input" class="form-label"></label>
<input type="password" class="form-control" id="delete-password-input" required>
</div>
<div id="delete-password-error" class="text-danger"></div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"></button>
<button type="button" id="confirm-delete-btn" class="btn btn-danger"></button>
</div>
</div>
</div>
</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');
const deleteMarkedBtn = document.getElementById('delete-marked-btn');
const deletePasswordModal = new bootstrap.Modal(document.getElementById('delete-password-modal'));
const deletePasswordForm = document.getElementById('delete-password-form');
const deletePasswordInput = document.getElementById('delete-password-input');
const deletePasswordError = document.getElementById('delete-password-error');
const confirmDeleteBtn = document.getElementById('confirm-delete-btn');
const setDeletionPasswordForm = document.getElementById('set-deletion-password-form');
const deletionPasswordInput = document.getElementById('deletion-password');
const setDeletionPasswordStatus = document.getElementById('set-deletion-password-status');
const selectAllBtn = document.getElementById('select-all-btn');
let standardItems = [];
let translations = {};
let currentShoppingList = [];
let currentUser = {};
let socket;
let isDeletionPasswordSet = false; // New global variable
// --- 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';
}
} else if (data.type === 'update') {
fetchItems();
}
};
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 = document.documentElement.lang;
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;
document.getElementById('set-deletion-password-title').textContent = translations.set_deletion_password_title;
document.getElementById('deletion-password').placeholder = translations.deletion_password_placeholder;
document.getElementById('set-deletion-password-button').textContent = translations.set_deletion_password_button;
}
// Deletion Modal
if (document.getElementById('delete-password-modal-label')) {
document.getElementById('delete-password-modal-label').textContent = translations.delete_password_modal_title;
document.getElementById('delete-password-label').textContent = translations.delete_password_label;
document.querySelector('#delete-password-modal .btn-secondary').textContent = translations.cancel_button;
document.getElementById('confirm-delete-btn').textContent = translations.delete_button;
}
// Common elements
if (document.getElementById('logout-btn')) {
document.getElementById('logout-btn').textContent = translations.logout_button;
}
}
async function fetchStandardItems() {
const lang = document.documentElement.lang;
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 = '';
let markedItemsCount = 0;
data.items.forEach(item => {
const li = document.createElement('li');
li.className = 'list-group-item';
const itemName = document.createElement('span');
itemName.textContent = item.name;
li.appendChild(itemName);
const switchContainer = document.createElement('div');
switchContainer.className = 'form-check form-switch';
const switchInput = document.createElement('input');
switchInput.className = 'form-check-input';
switchInput.type = 'checkbox';
switchInput.checked = item.marked;
switchInput.addEventListener('change', () => toggleMarkItem(item.id));
switchContainer.appendChild(switchInput);
li.appendChild(switchContainer);
if (item.marked) {
markedItemsCount++;
}
itemList.appendChild(li);
});
if (markedItemsCount > 0) {
deleteMarkedBtn.style.display = 'block';
} else {
deleteMarkedBtn.style.display = 'none';
}
handleInputChange();
}
async function toggleMarkItem(id) {
const response = await fetch(`/api/items/${id}/mark`, {
method: 'PUT',
headers: getAuthHeaders()
});
if (response.status === 401) return handleLogout();
await fetchItems();
}
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 = '';
suggestionInfo.style.display = 'none';
itemNameInput.focus();
}
async function sendNotification() {
const lang = document.documentElement.lang;
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");
}
}
// New function to call the delete marked items API
async function callDeleteMarkedItemsApi(password) {
const response = await fetch('/api/items/delete-marked', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ password: password })
});
if (response.status === 401) {
const errorData = await response.json(); // Get the error detail from the response
if (errorData.detail === "Incorrect password") {
deletePasswordError.textContent = translations.incorrect_password;
} else {
handleLogout(); // This is a session issue
}
} else if (response.ok) {
deletePasswordModal.hide();
await fetchItems();
} else {
deletePasswordError.textContent = translations.generic_error;
}
}
deleteMarkedBtn.addEventListener('click', () => {
if (isDeletionPasswordSet) {
deletePasswordModal.show();
} else {
// If no password is set, delete directly without showing the modal.
callDeleteMarkedItemsApi('');
}
});
deletePasswordForm.addEventListener('submit', (event) => {
event.preventDefault();
confirmDeleteBtn.click();
});
confirmDeleteBtn.addEventListener('click', async () => {
const password = deletePasswordInput.value;
// Always send the password (can be empty) to the backend.
// The backend will determine if a password is required.
await callDeleteMarkedItemsApi(password);
});
// Clear password input on modal close
deletePasswordModal._element.addEventListener('hidden.bs.modal', () => {
deletePasswordInput.value = '';
deletePasswordError.textContent = '';
});
async function getDeletionPassword() {
const response = await fetch('/api/settings/deletion-password', { headers: getAuthHeaders() });
if (response.status === 401) return handleLogout();
const data = await response.json();
isDeletionPasswordSet = data.is_set; // Update the global variable
if (data.is_set) {
document.getElementById('password-set-message').style.display = 'block';
document.getElementById('set-deletion-password-form').style.display = 'none';
} else {
document.getElementById('password-set-message').style.display = 'none';
document.getElementById('set-deletion-password-form').style.display = 'block';
}
}
document.getElementById('change-password-btn').addEventListener('click', () => {
document.getElementById('password-set-message').style.display = 'none';
document.getElementById('set-deletion-password-form').style.display = 'block';
});
document.getElementById('delete-password-btn').addEventListener('click', async () => {
const response = await fetch('/api/settings/deletion-password', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ password: '' })
});
if (response.ok) {
await getDeletionPassword();
}
});
async function setDeletionPassword(event) {
event.preventDefault();
const password = deletionPasswordInput.value;
const response = await fetch('/api/settings/deletion-password', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ password: password })
});
if (response.status === 401) return handleLogout();
if (response.ok) {
setDeletionPasswordStatus.textContent = translations.password_set_success;
setDeletionPasswordStatus.className = 'mt-2 text-success';
await getDeletionPassword();
} else {
const data = await response.json();
setDeletionPasswordStatus.textContent = `Error: ${data.detail}`;
setDeletionPasswordStatus.className = 'mt-2 text-danger';
}
}
async function selectAllItems() {
const allMarked = currentShoppingList.every(item => item.marked);
for (const item of currentShoppingList) {
if (allMarked || !item.marked) {
await toggleMarkItem(item.id);
}
}
}
// --- 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 = '';
suggestionInfo.style.display = 'none';
});
addOneButton.addEventListener('click', async () => {
await addItemsFromInput(itemNameInput.value, false);
itemNameInput.value = '';
suggestionBox.innerHTML = '';
suggestionInfo.style.display = 'none';
});
itemNameInput.addEventListener('input', handleInputChange);
notifyBtn.addEventListener('click', sendNotification);
selectAllBtn.addEventListener('click', selectAllItems);
setDeletionPasswordForm.addEventListener('submit', setDeletionPassword);
const toggleDeletionPassword = document.getElementById('toggle-deletion-password');
toggleDeletionPassword.addEventListener('click', () => {
const passwordInput = document.getElementById('deletion-password');
const type = passwordInput.getAttribute('type') === 'password' ? 'text' : 'password';
passwordInput.setAttribute('type', type);
const eyeIcon = toggleDeletionPassword.querySelector('.bi-eye');
const eyeSlashIcon = toggleDeletionPassword.querySelector('.bi-eye-slash');
if (type === 'password') {
eyeIcon.style.display = 'block';
eyeSlashIcon.style.display = 'none';
} else {
eyeIcon.style.display = 'none';
eyeSlashIcon.style.display = 'block';
}
});
// --- Initialisierung ---
async function initApp() {
// NOTE: Translations are now fetched before this function is called.
await fetchCurrentUser();
const promises = [
fetchStandardItems(),
fetchItems(),
fetchDbStatus(),
getDeletionPassword()
];
if (currentUser.is_admin) {
promises.push(fetchAndRenderUsers());
}
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 || !currentUser.is_admin) return;
const response = await fetch('/api/users', { headers: getAuthHeaders() });
if (response.status === 401) return handleLogout();
if (response.status === 403) return; // Non-admin users will get a 403 error
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
const themeSwitcher = document.getElementById('theme-switcher');
themeSwitcher.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
themeSwitcher.textContent = newTheme === 'dark' ? '☀️' : '🌙';
});
// Set initial theme switcher icon
const currentTheme = document.documentElement.getAttribute('data-theme');
if (currentTheme === 'dark') {
themeSwitcher.textContent = '☀️';
} else {
themeSwitcher.textContent = '🌙';
}
</script>
</body>
</html>