feat: add SSO user favorites dashboard
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s

This commit is contained in:
Kroonk
2026-05-21 23:18:23 +02:00
parent fb540813ff
commit d40329ae7c
12 changed files with 1039 additions and 10 deletions

239
favorites.js Normal file
View File

@@ -0,0 +1,239 @@
const favoriteIcons = {
star: '<polygon points="12 2 15 8.5 22 9.3 16.8 14 18.2 21 12 17.4 5.8 21 7.2 14 2 9.3 9 8.5 12 2"></polygon>',
link: '<path d="M10 13a5 5 0 0 0 7.1 0l2.8-2.8a5 5 0 0 0-7.1-7.1l-1.6 1.6"></path><path d="M14 11a5 5 0 0 0-7.1 0l-2.8 2.8a5 5 0 0 0 7.1 7.1l1.6-1.6"></path>',
play: '<circle cx="12" cy="12" r="10"></circle><path d="m10 8 6 4-6 4z"></path>',
note: '<path d="M4 3h11l5 5v13H4z"></path><path d="M14 3v6h6"></path><path d="M8 13h8"></path><path d="M8 17h6"></path>',
book: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>',
code: '<path d="m16 18 6-6-6-6"></path><path d="m8 6-6 6 6 6"></path>',
mail: '<rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="m3 7 9 6 9-6"></path>',
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"></rect><path d="M16 2v4"></path><path d="M8 2v4"></path><path d="M3 10h18"></path>',
image: '<rect x="3" y="3" width="18" height="18" rx="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><path d="m21 15-5-5L5 21"></path>',
tool: '<path d="M14.7 6.3a4 4 0 0 0-5 5L3 18v3h3l6.7-6.7a4 4 0 0 0 5-5l-2.8 2.8-2-2z"></path>'
};
const iconLabels = {
star: 'Stern',
link: 'Link',
play: 'Video',
note: 'Notiz',
book: 'Buch',
code: 'Code',
mail: 'Mail',
calendar: 'Kalender',
image: 'Bild',
tool: 'Tool'
};
const loginButton = document.querySelector('#favoritesLoginButton');
const logoutButton = document.querySelector('#favoritesLogoutButton');
const loginPanel = document.querySelector('#favoritesLoginPanel');
const panel = document.querySelector('#favoritesPanel');
const message = document.querySelector('#favoritesMessage');
const userBox = document.querySelector('#favoritesUser');
const editor = document.querySelector('#favoriteEditor');
const preview = document.querySelector('#favoritePreview');
const addButton = document.querySelector('#addFavoriteButton');
const saveButton = document.querySelector('#saveFavoritesButton');
const reloadButton = document.querySelector('#reloadFavoritesButton');
let favorites = [];
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function createDefaultFavorite() {
return {
id: crypto.randomUUID(),
name: 'Neuer Link',
url: 'https://',
group: 'Favoriten',
description: '',
color: '#67e8f9',
icon: 'star',
logoUrl: '',
order: favorites.length
};
}
function iconOptions(selected) {
return Object.keys(favoriteIcons).map((icon) => (
`<option value="${icon}" ${icon === selected ? 'selected' : ''}>${iconLabels[icon]}</option>`
)).join('');
}
function syncFromInputs() {
editor.querySelectorAll('.favorite-edit-card').forEach((row) => {
const favorite = favorites[Number(row.dataset.index)];
if (!favorite) return;
row.querySelectorAll('[data-field]').forEach((input) => {
favorite[input.dataset.field] = input.value;
});
});
}
function moveFavorite(index, direction) {
const next = index + direction;
if (next < 0 || next >= favorites.length) return;
syncFromInputs();
[favorites[index], favorites[next]] = [favorites[next], favorites[index]];
favorites = favorites.map((favorite, order) => ({ ...favorite, order }));
render();
}
function removeFavorite(index) {
syncFromInputs();
favorites.splice(index, 1);
favorites = favorites.map((favorite, order) => ({ ...favorite, order }));
render();
}
function renderPreviewCard(favorite) {
const color = /^#[0-9a-fA-F]{6}$/.test(favorite.color) ? favorite.color : '#67e8f9';
const iconSvg = favoriteIcons[favorite.icon] || favoriteIcons.star;
let host = '';
try {
host = new URL(favorite.url).hostname;
} catch {
host = 'ungueltige-url';
}
return `
<article class="card favorite-card" style="--accent: ${color};">
<div class="card-icon favorite-icon" style="--accent: ${color};">
${favorite.logoUrl
? `<img src="${escapeHtml(favorite.logoUrl)}" alt="" loading="lazy">`
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${iconSvg}</svg>`}
</div>
<div class="card-content">
<span class="card-category">${escapeHtml(favorite.group || 'Favorit')}</span>
<h3>${escapeHtml(favorite.name)}</h3>
<p>${escapeHtml(favorite.description || 'Eigener Weblink')}</p>
</div>
<span class="card-domain">${escapeHtml(host)}</span>
</article>
`;
}
function renderPreview() {
preview.innerHTML = favorites.map(renderPreviewCard).join('') || '<div class="favorites-empty">Die Vorschau erscheint hier.</div>';
}
function render() {
editor.innerHTML = favorites.map((favorite, index) => `
<article class="favorite-edit-card" data-index="${index}">
<div class="favorite-edit-head">
<strong>${escapeHtml(favorite.name || 'Neuer Link')}</strong>
<div class="favorite-order-actions">
<button class="ghost-button mini" data-action="up" type="button" ${index === 0 ? 'disabled' : ''}>Hoch</button>
<button class="ghost-button mini" data-action="down" type="button" ${index === favorites.length - 1 ? 'disabled' : ''}>Runter</button>
<button class="ghost-button mini danger-button" data-action="remove" type="button">Entfernen</button>
</div>
</div>
<div class="favorite-form-grid">
<label>Beschriftung<input data-field="name" value="${escapeHtml(favorite.name)}" placeholder="YouTube"></label>
<label>Link<input data-field="url" value="${escapeHtml(favorite.url)}" placeholder="https://youtube.com"></label>
<label>Gruppe<input data-field="group" value="${escapeHtml(favorite.group)}" placeholder="Privat"></label>
<label>Beschreibung<input data-field="description" value="${escapeHtml(favorite.description)}" placeholder="Optionaler Untertitel"></label>
<label>Farbe<input data-field="color" type="color" value="${escapeHtml(favorite.color || '#67e8f9')}"></label>
<label>Icon<select data-field="icon">${iconOptions(favorite.icon || 'star')}</select></label>
<label class="favorite-form-wide">Logo URL<input data-field="logoUrl" value="${escapeHtml(favorite.logoUrl)}" placeholder="https://.../logo.png"></label>
</div>
</article>
`).join('') || '<div class="favorites-empty">Noch keine Favoriten angelegt.</div>';
renderPreview();
editor.querySelectorAll('[data-field]').forEach((input) => {
input.addEventListener('input', () => {
syncFromInputs();
renderPreview();
});
});
editor.querySelectorAll('[data-action]').forEach((button) => {
button.addEventListener('click', () => {
const index = Number(button.closest('.favorite-edit-card').dataset.index);
if (button.dataset.action === 'up') moveFavorite(index, -1);
if (button.dataset.action === 'down') moveFavorite(index, 1);
if (button.dataset.action === 'remove') removeFavorite(index);
});
});
}
async function loadFavorites() {
const response = await fetch('/api/favorites', {
cache: 'no-store',
headers: MischLabsAuth.authHeader()
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
userBox.textContent = `Angemeldet als ${payload.user?.name || MischLabsAuth.displayName()}`;
render();
}
async function saveFavorites() {
syncFromInputs();
const response = await fetch('/api/favorites', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...MischLabsAuth.authHeader()
},
body: JSON.stringify({ favorites })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
favorites = payload.favorites || [];
render();
userBox.textContent = 'Favoriten gespeichert.';
}
function updateAuthUi() {
const authenticated = MischLabsAuth.isAuthenticated();
loginButton.hidden = authenticated;
logoutButton.hidden = !authenticated;
loginPanel.hidden = authenticated;
panel.hidden = !authenticated;
}
async function boot() {
try {
await MischLabsAuth.handleCallback();
} catch (error) {
message.textContent = error.message;
}
updateAuthUi();
if (MischLabsAuth.isAuthenticated()) {
await loadFavorites();
}
}
loginButton.addEventListener('click', () => MischLabsAuth.login());
logoutButton.addEventListener('click', () => MischLabsAuth.logout());
addButton.addEventListener('click', () => {
syncFromInputs();
favorites.push(createDefaultFavorite());
render();
});
saveButton.addEventListener('click', () => {
saveFavorites().catch((error) => {
userBox.textContent = `Speichern fehlgeschlagen: ${error.message}`;
});
});
reloadButton.addEventListener('click', () => {
loadFavorites().catch((error) => {
userBox.textContent = `Laden fehlgeschlagen: ${error.message}`;
});
});
boot().catch((error) => {
message.textContent = `Favoritenseite konnte nicht starten: ${error.message}`;
});