Files
noteshop-webapp/static/index.html

807 lines
33 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">
<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>