Files
wizarr/app/templates/admin.html
Matthieu B 9b8c0248cd fix: resolve double scrollbar on LDAP settings and add experimental label
Add overflow-hidden to admin layout's h-screen container to prevent
body-level scrolling competing with #content's overflow-y-auto.
Mark LDAP settings as experimental with a badge.
2026-03-29 16:51:36 +02:00

390 lines
21 KiB
HTML

{% extends "base.html" %}
{% block title %}
{{ _("Admin") }}
{% endblock title %}
{% block main %}
<div class="flex flex-col h-screen overflow-hidden">
{% if update_available %}
<div class="w-full bg-primary text-white text-center py-1">
<a href="https://docs.wizarr.dev/getting-started/installation">
<span class="font-medium">A new update is available!</span>
</a>
</div>
{% endif %}
<!-- Desktop Navigation -->
<nav hx-ext="preload"
class="hidden md:block border-gray-200 px-2 sm:px-4 py-2.5 rounded-sm dark:bg-gray-900">
<div class="container flex flex-wrap items-center justify-between mx-auto">
<a href="{{ url_for('admin.home') }}" class="flex items-center">
<img src="{{ url_for('static', filename='wizarr-logo.png') }}"
alt="Wizarr Logo"
class="h-8 mr-3">
<span class="self-center text-xl font-semibold whitespace-nowrap dark:text-white">Wizarr</span>
</a>
<div class="w-full md:w-auto" id="navbar-default">
<div class="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 text-sm font-medium">
<!-- Main Navigation -->
<ul class="flex flex-col md:flex-row md:items-center gap-2 md:gap-4 md:space-y-0 space-y-2">
{% set activity_url = url_for('activity.activity_dashboard') %}
{% set tabs = [('/home', '#', 'Home'), ('/invites', '#invites', 'Invitations'), ('/users', '#users', 'Users'), (activity_url, '#activity', 'Activity'), ('/settings', '#settings', 'Settings')] %}
{% for href, hash, label in tabs %}
<li>
<button hx-get="{{ href }}"
preload="mouseover"
hx-trigger="click"
hx-target="#content"
hx-swap="innerHTML"
hx-push-url="{{ hash }}"
class="nav-btn block px-4 py-2 rounded-lg text-gray-600 dark:text-gray-400 hover:text-primary hover:bg-gray-50 dark:hover:text-white dark:hover:bg-gray-800 transition-all duration-200"
onmouseenter="this.nextElementSibling.dispatchEvent(new Event('hover'))">
{{ _(label) }}
</button>
<!-- Page-specific prefetches -->
{% if href == "/home" %}
<div hx-get="/now-playing-cards"
hx-trigger="hover once"
hx-swap="none"
style="display: none"></div>
{% elif href == "/invites" %}
<div hx-post="/invite/table"
hx-trigger="hover once"
hx-swap="none"
style="display: none"></div>
{% elif href == "/users" %}
<div hx-get="/users/table"
hx-trigger="hover once"
hx-swap="none"
style="display: none"></div>
{% elif href == activity_url %}
<div hx-get="{{ url_for('activity.activity_grid') }}"
hx-trigger="hover once"
hx-swap="none"
style="display: none"></div>
<div hx-get="{{ url_for('activity.activity_stats') }}"
hx-trigger="hover once"
hx-swap="none"
style="display: none"></div>
{% elif href == "/plus" %}
<div hx-get="/plus/overview"
hx-trigger="hover once"
hx-swap="none"
style="display: none"></div>
{% elif href == "/settings" %}
<div hx-get="/settings/general"
hx-trigger="hover once"
hx-swap="none"
style="display: none"></div>
<div hx-get="/settings/api-keys"
hx-trigger="hover once"
hx-swap="none"
style="display: none"></div>
{% endif %}
</li>
{% endfor %}
</ul>
<!-- Divider -->
<div class="hidden md:block w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
<!-- User Controls -->
<div class="flex flex-col md:flex-row md:items-center gap-3 md:gap-4 pt-4 md:pt-0 border-t md:border-0 border-gray-200 dark:border-gray-700">
<!-- User Profile -->
<div class="relative" x-data="{ open: false }">
<button @click="open = !open"
class="flex items-center gap-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-white px-3 py-2 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800 transition-all duration-200">
<div class="w-8 h-8 bg-primary rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z">
</path>
</svg>
</div>
<span class="text-sm font-medium">{{ current_user.username }}</span>
<svg class="w-4 h-4 transition-transform"
:class="open ? 'rotate-180' : ''"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- Dropdown Menu -->
<div x-show="open"
@click.away="open = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div class="py-1">
<a href="#profile"
hx-get="/profile"
hx-target="#content"
hx-swap="innerHTML"
hx-push-url="#profile"
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
<div class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z">
</path>
</svg>
{{ _("Profile") }}
</div>
</a>
<div class="border-t border-gray-200 dark:border-gray-600"></div>
<a href="{{ url_for('auth.logout') }}"
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
<div class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1">
</path>
</svg>
{{ _("Logout") }}
</div>
</a>
</div>
</div>
</div>
<!-- Theme toggle -->
<button id="theme-toggle"
type="button"
class="text-gray-600 dark:text-gray-400 hover:text-primary hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-hidden p-2 rounded-md transition-all duration-200">
<svg id="theme-toggle-dark-icon"
class="block dark:hidden w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
<svg id="theme-toggle-light-icon"
class="hidden dark:block w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" />
</svg>
</button>
</div>
</div>
</div>
</div>
</nav>
<!-- Mobile Header (logo only) -->
<header class="md:hidden border-gray-200 px-4 py-2.5 dark:bg-gray-900 border-b dark:border-gray-700">
<div class="flex items-center justify-between">
<a href="{{ url_for('admin.home') }}" class="flex items-center">
<img src="{{ url_for('static', filename='wizarr-logo.png') }}"
alt="Wizarr Logo"
class="h-8 mr-3">
<span class="self-center text-xl font-semibold whitespace-nowrap dark:text-white">Wizarr</span>
</a>
<div class="flex items-center gap-2">
<!-- User Profile for mobile -->
<button hx-get="/profile"
hx-trigger="click"
hx-target="#content"
hx-swap="innerHTML"
hx-push-url="#profile"
class="text-gray-600 dark:text-gray-400 hover:text-primary hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-hidden p-2 rounded-md transition-all duration-200">
<div class="w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z">
</path>
</svg>
</div>
</button>
<!-- Theme toggle for mobile -->
<button id="mobile-theme-toggle"
type="button"
class="text-gray-600 dark:text-gray-400 hover:text-primary hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-hidden p-2 rounded-md transition-all duration-200">
<svg class="block dark:hidden w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
<svg class="hidden dark:block w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" />
</svg>
</button>
</div>
</div>
</header>
<div id="content" class="flex-1 z-10 overflow-y-auto pb-16 md:pb-0"></div>
<!-- Mobile Bottom Navigation -->
<nav class="md:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 z-50">
<div class="grid grid-cols-5 h-16">
{% set activity_url = url_for('activity.activity_dashboard') %}
{% set mobile_tabs = [
('/home', '#', 'Home', 'm4 12 8-8 8 8M6 10.5V19a1 1 0 0 0 1 1h3v-3a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v3h3a1 1 0 0 0 1-1v-8.5'),
('/invites', '#invites', 'Invitations', 'm3.5 5.5 7.893 6.036a1 1 0 0 0 1.214 0L20.5 5.5M4 19h16a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1Z'),
('/users', '#users', 'Users', 'M16 19h4a1 1 0 0 0 1-1v-1a3 3 0 0 0-3-3h-2m-2.236-4a3 3 0 1 0 0-4M3 18v-1a3 3 0 0 1 3-3h4a3 3 0 0 1 3 3v1a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1Zm8-10a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z'),
(activity_url, '#activity', 'Activity', 'M5 3a1 1 0 0 1 1 1v12a1 1 0 1 1-2 0V4a1 1 0 0 1 1-1Zm7 4a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm7 4a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Z'),
('/settings', '#settings', 'Settings', 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 0 1 6 0z')
] %}
{% for href, hash, label, icon_path in mobile_tabs %}
<button hx-get="{{ href }}"
hx-trigger="click"
hx-target="#content"
hx-swap="innerHTML"
hx-push-url="{{ hash }}"
class="mobile-nav-btn flex flex-col items-center justify-center py-2 px-1 text-gray-500 dark:text-gray-400 hover:text-primary dark:hover:text-white transition-colors duration-200">
<svg class="w-6 h-6 mb-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}"></path>
</svg>
<span class="text-xs font-medium">{{ _(label) }}</span>
</button>
{% endfor %}
</div>
</nav>
{% set preload_hashes = ["#invites", "#users", "#activity", "#settings", "#profile"] %}
{% set preload_pages = ["invites", "users", "activity", "settings", "profile"] %}
<div hx-get="/home"
hx-target="#content"
hx-swap="innerHTML"
hx-trigger="load[!{{ preload_hashes | tojson }}.includes(window.location.hash)]"
style="display:none"></div>
{% for page in preload_pages %}
<div hx-get="/{{ page }}"
hx-target="#content"
hx-swap="innerHTML"
hx-trigger="load[window.location.hash === '#{{ page }}']"
style="display:none"></div>
{% endfor %}
<!-- Support Banner (Flask/Jinja) -->
<div id="support-banner"
class="hidden md:flex bg-gray-800 text-white text-center flex-col md:flex-row justify-center items-center gap-4 py-1">
<!-- Call-to-action -->
<span class="text-sm sm:text-base">
If you like Wizarr, please
<a href="https://github.com/sponsors/mtthidoteu"
class="underline font-semibold text-indigo-300 hover:text-indigo-200 transition">support development</a>
♥️
or
<a href="https://github.com/mtthidoteu"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-1 text-yellow-300 hover:text-yellow-200 transition underline">
help me find work
</a>
💼
</span>
<!-- Avatars provided by the Flask template variable `sponsors` -->
<div id="sponsor-carousel"
class="flex items-center gap-6 overflow-hidden min-h-[60px]">
{# sponsors come as [{"sponsorEntity": {...}}, ...] #}
{% for s in sponsors %}
{% set p = s.sponsorEntity %}
<a href="{{ p.url }}"
target="_blank"
rel="noopener"
class="flex items-center w-80 justify-center animate__animated
{% if not loop.first %}
hidden
{% else %}
animate__fadeIn
{% endif %}">
<span class="text-xs mr-2 text-gray-300 truncate">Thank you,</span>
<img src="{{ p.avatarUrl }}"
alt="{{ p.login }}"
width="36"
height="36"
class="rounded-full shadow-lg shrink-0">
<span class="text-xs ml-2 text-gray-300 truncate">{{ p.login|capitalize }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
<script>
(function() {
const ROTATE_MS = 3000;
const items = Array.from(document.querySelectorAll('#sponsor-carousel a'));
if (items.length <= 1) return;
let current = 0;
setInterval(() => {
const prev = items[current];
// Fade out the current item
prev.classList.remove('animate__fadeIn');
prev.classList.add('animate__fadeOut');
// After fade-out animation, hide it
setTimeout(() => {
prev.classList.add('hidden');
prev.classList.remove('animate__animated', 'animate__fadeOut');
// Show next item with fade-in
current = (current + 1) % items.length;
const next = items[current];
next.classList.remove('hidden');
next.classList.add('animate__animated', 'animate__fadeIn');
}, 500); // Animate.css default fade duration (~500ms)
}, ROTATE_MS);
})();
</script>
<script>
// Highlight active tab with pill-style background instead of underline
function highlightNav() {
const hash = window.location.hash || "#";
// Highlight desktop navigation
document.querySelectorAll('.nav-btn').forEach(btn => {
const target = btn.getAttribute('hx-push-url');
if (target === hash) {
btn.classList.add('bg-primary', 'text-white', 'font-semibold', 'shadow-sm');
btn.classList.remove('text-gray-600', 'dark:text-gray-400', 'hover:text-primary', 'hover:bg-gray-50', 'dark:hover:text-white', 'dark:hover:bg-gray-800');
} else {
btn.classList.remove('bg-primary', 'text-white', 'font-semibold', 'shadow-sm');
btn.classList.add('text-gray-600', 'dark:text-gray-400', 'hover:text-primary', 'hover:bg-gray-50', 'dark:hover:text-white', 'dark:hover:bg-gray-800');
}
});
// Highlight mobile navigation
document.querySelectorAll('.mobile-nav-btn').forEach(btn => {
const target = btn.getAttribute('hx-push-url');
if (target === hash) {
btn.classList.add('text-primary', 'dark:text-primary');
btn.classList.remove('text-gray-500', 'dark:text-gray-400');
} else {
btn.classList.remove('text-primary', 'dark:text-primary');
btn.classList.add('text-gray-500', 'dark:text-gray-400');
}
});
}
// Mobile theme toggle functionality
function setupMobileThemeToggle() {
const mobileToggle = document.getElementById('mobile-theme-toggle');
const desktopToggle = document.getElementById('theme-toggle');
if (mobileToggle && desktopToggle) {
mobileToggle.addEventListener('click', function() {
// Trigger the existing desktop theme toggle functionality
desktopToggle.click();
});
}
}
window.addEventListener('hashchange', highlightNav);
document.addEventListener('DOMContentLoaded', function() {
highlightNav();
setupMobileThemeToggle();
});
// Also tie into HTMX after content swap to ensure hash may be set programmatically
document.body.addEventListener('htmx:afterSwap', highlightNav);
</script>
{% endblock main %}