eerrrrrr
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m57s
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m57s
This commit is contained in:
BIN
templates/.DS_Store
vendored
BIN
templates/.DS_Store
vendored
Binary file not shown.
@@ -1,4 +1,5 @@
|
||||
<div class="modal fade" id="authModal" tabindex="-1" aria-labelledby="authModalLabel" aria-hidden="true">
|
||||
{% set registrationEnabled = registration_enabled ?? true %}
|
||||
<div class="modal fade" id="authModal" tabindex="-1" aria-labelledby="authModalLabel" aria-hidden="true" data-registration-enabled="{{ registrationEnabled ? '1' : '0' }}">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -24,21 +25,25 @@
|
||||
<input type="hidden" name="_target_path" value="" data-auth-target />
|
||||
<input type="hidden" name="_failure_path" value="" data-auth-failure />
|
||||
<button class="btn btn-success" type="submit">Login</button>
|
||||
<button class="btn btn-outline-success" type="button" data-auth-open-register>Sign up</button>
|
||||
<button class="btn btn-outline-success" type="button" data-auth-open-register {% if not registrationEnabled %}disabled title="Registration is currently disabled"{% endif %}>Sign up</button>
|
||||
</form>
|
||||
<div class="text-danger small mt-2 d-none" data-auth-login-error></div>
|
||||
<div class="text-danger small mt-2 d-none" data-auth-login-error></div>
|
||||
</div>
|
||||
<div data-auth-panel="register" class="d-none">
|
||||
<form data-auth-register action="{{ path('app_register') }}" method="post" class="vstack gap-2">
|
||||
<input type="hidden" name="registration_form[_token]" value="{{ csrf_token('registration_form') }}" />
|
||||
<div><label class="form-label">Email</label><input class="form-control" type="email" name="registration_form[email]" required /></div>
|
||||
<div><label class="form-label">Display name (optional)</label><input class="form-control" type="text" name="registration_form[displayName]" maxlength="120" /></div>
|
||||
<div><label class="form-label">Password</label><input class="form-control" type="password" name="registration_form[plainPassword][first]" minlength="8" required /></div>
|
||||
<div><label class="form-label">Repeat password</label><input class="form-control" type="password" name="registration_form[plainPassword][second]" minlength="8" required /></div>
|
||||
<button class="btn btn-success" type="submit">Create account</button>
|
||||
<button class="btn btn-outline-secondary" type="button" data-auth-open-login>Back to login</button>
|
||||
</form>
|
||||
<div class="text-danger small mt-2 d-none" data-auth-register-error></div>
|
||||
{% if registrationEnabled %}
|
||||
<form data-auth-register action="{{ path('app_register') }}" method="post" class="vstack gap-2">
|
||||
<input type="hidden" name="registration_form[_token]" value="{{ csrf_token('registration_form') }}" />
|
||||
<div><label class="form-label">Email</label><input class="form-control" type="email" name="registration_form[email]" required /></div>
|
||||
<div><label class="form-label">Display name (optional)</label><input class="form-control" type="text" name="registration_form[displayName]" maxlength="120" /></div>
|
||||
<div><label class="form-label">Password</label><input class="form-control" type="password" name="registration_form[plainPassword][first]" minlength="8" required /></div>
|
||||
<div><label class="form-label">Repeat password</label><input class="form-control" type="password" name="registration_form[plainPassword][second]" minlength="8" required /></div>
|
||||
<button class="btn btn-success" type="submit">Create account</button>
|
||||
<button class="btn btn-outline-secondary" type="button" data-auth-open-login>Back to login</button>
|
||||
</form>
|
||||
<div class="text-danger small mt-2 d-none" data-auth-register-error></div>
|
||||
{% else %}
|
||||
<div class="alert alert-info mb-0">Registration is currently disabled. Please check back later.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +60,21 @@
|
||||
panels.forEach(p => p.classList.toggle('d-none', p.getAttribute('data-auth-panel') !== kind));
|
||||
bsModal.show();
|
||||
}
|
||||
modalEl.querySelector('[data-auth-open-register]')?.addEventListener('click', ()=> showPanel('register'));
|
||||
const registrationEnabled = modalEl.getAttribute('data-registration-enabled') === '1';
|
||||
const registerToggle = modalEl.querySelector('[data-auth-open-register]');
|
||||
if (registerToggle) {
|
||||
registerToggle.addEventListener('click', (e) => {
|
||||
if (!registrationEnabled) {
|
||||
e.preventDefault();
|
||||
if (loginError) {
|
||||
loginError.textContent = 'Registration is currently disabled.';
|
||||
loginError.classList.remove('d-none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
showPanel('register');
|
||||
});
|
||||
}
|
||||
modalEl.querySelector('[data-auth-open-login]')?.addEventListener('click', ()=> showPanel('login'));
|
||||
document.querySelectorAll('[data-open-auth]')?.forEach(btn => {
|
||||
btn.addEventListener('click', (e)=>{ e.preventDefault(); showPanel(btn.getAttribute('data-open-auth') || 'login'); });
|
||||
@@ -89,7 +108,8 @@
|
||||
// AJAX registration
|
||||
const regForm = modalEl.querySelector('form[data-auth-register]');
|
||||
const regError = modalEl.querySelector('[data-auth-register-error]');
|
||||
if (regForm) {
|
||||
|
||||
if (regForm && registrationEnabled) {
|
||||
regForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (regError) { regError.classList.add('d-none'); }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary" data-auth-header>
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-bold" href="{{ path('album_search') }}">Tonehaus</a>
|
||||
<a class="navbar-brand fw-bold ps-2" href="{{ path('album_search') }}">tonehaus</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
@@ -13,18 +13,25 @@
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
{% if app.user %}
|
||||
<div class="dropdown">
|
||||
{% set avatar = app.user.profileImagePath %}
|
||||
<button class="btn btn-outline-secondary dropdown-toggle d-flex align-items-center gap-2" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
|
||||
<path fill-rule="evenodd" d="M14 14s-1-1.5-6-1.5S2 14 2 14s1-4 6-4 6 4 6 4z"/>
|
||||
</svg>
|
||||
{% if avatar %}
|
||||
<img src="{{ avatar }}" alt="Profile picture" class="rounded-circle border" width="28" height="28" style="object-fit: cover;">
|
||||
{% else %}
|
||||
<span class="rounded-circle bg-secondary text-white d-inline-flex align-items-center justify-content-center" style="width:28px;height:28px;">
|
||||
{{ (app.user.displayName ?? app.user.userIdentifier)|slice(0,1)|upper }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="text-truncate" style="max-width: 180px;">{{ app.user.displayName ?? app.user.userIdentifier }}</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
{% if is_granted('ROLE_MODERATOR') %}
|
||||
<li><h6 class="dropdown-header">Site</h6></li>
|
||||
<li><a class="dropdown-item" href="{{ path('admin_dashboard') }}">Site Dashboard</a></li>
|
||||
<li><a class="dropdown-item" href="{{ path('admin_settings') }}">Site Settings</a></li>
|
||||
<li><a class="dropdown-item" href="{{ path('admin_dashboard') }}">Dashboard</a></li>
|
||||
<li><a class="dropdown-item" href="{{ path('admin_users') }}">User Management</a></li>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<li><a class="dropdown-item" href="{{ path('admin_settings') }}">Settings</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
<li><h6 class="dropdown-header">User</h6></li>
|
||||
|
||||
@@ -22,41 +22,22 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-secondary">User Type</div>
|
||||
<div class="display-6">{{ userType }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Profile</h2>
|
||||
<div class="row g-3 align-items-center">
|
||||
<div class="col-auto">
|
||||
<a class="card h-100 text-reset text-decoration-none" href="{{ path('account_profile') }}">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
{% if profileImage %}
|
||||
<img src="{{ profileImage }}" alt="Profile picture" class="rounded-circle border" width="72" height="72" style="object-fit: cover;">
|
||||
<img src="{{ profileImage }}" alt="Profile picture" class="rounded-circle border" width="64" height="64" style="object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="rounded-circle bg-secondary d-flex align-items-center justify-content-center text-white" style="width:72px;height:72px;">
|
||||
<div class="rounded-circle bg-secondary d-flex align-items-center justify-content-center text-white flex-shrink-0" style="width:64px;height:64px;">
|
||||
<span class="fw-semibold">{{ (displayName ?? email)|slice(0,1)|upper }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="text-secondary">User Type</div>
|
||||
<div class="fw-semibold">{{ displayName ?? email }}</div>
|
||||
<div class="text-secondary small">{{ userType }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-5">
|
||||
<label class="form-label">Email</label>
|
||||
<input class="form-control" value="{{ email }}" readonly />
|
||||
</div>
|
||||
<div class="col-sm-5">
|
||||
<label class="form-label">Display name</label>
|
||||
<input class="form-control" value="{{ displayName }}" readonly />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<a class="btn btn-outline-primary me-2" href="{{ path('account_profile') }}">Edit profile</a>
|
||||
<a class="btn btn-outline-secondary" href="{{ path('account_password') }}">Change password</a>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,41 +6,73 @@
|
||||
<div class="alert alert-success">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="h6 mb-3">Current picture</h2>
|
||||
{% if profileImage %}
|
||||
<img src="{{ profileImage }}" alt="Profile picture" class="rounded-circle border mb-3" width="160" height="160" style="object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="rounded-circle bg-secondary text-white d-inline-flex align-items-center justify-content-center mb-3" style="width:160px;height:160px;">
|
||||
<span class="fs-3">{{ (app.user.displayName ?? app.user.userIdentifier)|slice(0,1)|upper }}</span>
|
||||
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-5">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Profile details</h2>
|
||||
<div class="text-center mb-3">
|
||||
{% if profileImage %}
|
||||
<img src="{{ profileImage }}" alt="Profile picture" class="rounded-circle border mb-3" width="160" height="160" style="object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="rounded-circle bg-secondary text-white d-inline-flex align-items-center justify-content-center mb-3" style="width:160px;height:160px;">
|
||||
<span class="fs-3">{{ (app.user.displayName ?? app.user.userIdentifier)|slice(0,1)|upper }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="text-secondary small mb-0">Images up to 4MB. JPG or PNG recommended.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="text-secondary small mb-0">Images up to 4MB. JPG or PNG recommended.</p>
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.profileImage, null, {label_attr: {class: 'form-label'}}) }}
|
||||
{{ form_widget(form.profileImage, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.profileImage) }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.displayName, null, {label_attr: {class: 'form-label'}}) }}
|
||||
{{ form_widget(form.displayName, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.displayName) }}
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
{{ form_label(form.email, null, {label_attr: {class: 'form-label'}}) }}
|
||||
{{ form_widget(form.email, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.email) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
|
||||
<div class="mb-3">{{ form_row(form.email) }}</div>
|
||||
<div class="mb-3">{{ form_row(form.displayName) }}</div>
|
||||
<div class="mb-3">{{ form_row(form.profileImage) }}</div>
|
||||
<hr>
|
||||
<p class="text-secondary small mb-3">Password change is optional. Provide your current password only if you want to update it.</p>
|
||||
<div class="mb-3">{{ form_row(form.currentPassword) }}</div>
|
||||
<div class="mb-3">{{ form_row(form.newPassword) }}</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Password</h2>
|
||||
<p class="text-secondary small">Leave the fields below blank to keep your current password. You'll need to supply your existing password whenever you create a new one.</p>
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.currentPassword, null, {label_attr: {class: 'form-label'}}) }}
|
||||
{{ form_widget(form.currentPassword, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.currentPassword) }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form_errors(form.newPassword) }}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
{{ form_label(form.newPassword.first, null, {label_attr: {class: 'form-label'}}) }}
|
||||
{{ form_widget(form.newPassword.first, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.newPassword.first) }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form_label(form.newPassword.second, null, {label_attr: {class: 'form-label'}}) }}
|
||||
{{ form_widget(form.newPassword.second, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.newPassword.second) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-success" type="submit">Save changes</button>
|
||||
<a class="btn btn-link" href="{{ path('account_dashboard') }}">Cancel</a>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -10,24 +10,76 @@
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="themeToggle">
|
||||
<label class="form-check-label" for="themeToggle">Dark mode</label>
|
||||
</div>
|
||||
<small class="text-secondary">Your choice is saved in a cookie.</small>
|
||||
<small class="text-secondary d-block mb-3">Theme and accent settings are stored in cookies.</small>
|
||||
|
||||
<div class="mt-2">
|
||||
<label class="form-label mb-2" for="accentPicker">Accent colour</label>
|
||||
<div class="d-flex align-items-center gap-3 flex-wrap">
|
||||
<input class="form-control form-control-color" type="color" id="accentPicker" value="#6750a4" title="Choose accent colour">
|
||||
<span class="text-secondary small">Matches the Material-inspired palette across buttons, links, and highlights.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const key = 'theme';
|
||||
const root = document.documentElement;
|
||||
const current = (document.cookie.match(/(?:^|; )theme=([^;]+)/)?.[1] || '').replace(/\+/g,' ');
|
||||
const initial = current || root.getAttribute('data-bs-theme') || 'light';
|
||||
const THEME_KEY = 'theme';
|
||||
const ACCENT_KEY = 'accentColor';
|
||||
const DEFAULT_ACCENT = '#6750a4';
|
||||
const toggle = document.getElementById('themeToggle');
|
||||
toggle.checked = initial === 'dark';
|
||||
function setTheme(t){
|
||||
root.setAttribute('data-bs-theme', t);
|
||||
const d = new Date(); d.setFullYear(d.getFullYear()+1);
|
||||
document.cookie = key+'='+t+'; path=/; SameSite=Lax; expires='+d.toUTCString();
|
||||
const accentPicker = document.getElementById('accentPicker');
|
||||
|
||||
function getCookie(name) {
|
||||
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]+)'));
|
||||
return match ? decodeURIComponent(match[1]) : '';
|
||||
}
|
||||
|
||||
function persist(name, value) {
|
||||
const d = new Date();
|
||||
d.setFullYear(d.getFullYear() + 1);
|
||||
document.cookie = name + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax; expires=' + d.toUTCString();
|
||||
}
|
||||
|
||||
function accentContrast(hex) {
|
||||
const normalized = hex.replace('#', '');
|
||||
if (normalized.length !== 6) {
|
||||
return '#ffffff';
|
||||
}
|
||||
const r = parseInt(normalized.substring(0, 2), 16);
|
||||
const g = parseInt(normalized.substring(2, 4), 16);
|
||||
const b = parseInt(normalized.substring(4, 6), 16);
|
||||
const luminance = (0.299 * r) + (0.587 * g) + (0.114 * b);
|
||||
return luminance > 180 ? '#1c1b20' : '#ffffff';
|
||||
}
|
||||
|
||||
if (toggle) {
|
||||
const initialTheme = getCookie(THEME_KEY) || root.getAttribute('data-bs-theme') || 'light';
|
||||
toggle.checked = initialTheme === 'dark';
|
||||
const setTheme = (theme) => {
|
||||
root.setAttribute('data-bs-theme', theme);
|
||||
persist(THEME_KEY, theme);
|
||||
};
|
||||
setTheme(initialTheme);
|
||||
toggle.addEventListener('change', () => setTheme(toggle.checked ? 'dark' : 'light'));
|
||||
}
|
||||
|
||||
if (accentPicker) {
|
||||
const storedAccent = getCookie(ACCENT_KEY) || DEFAULT_ACCENT;
|
||||
const validAccent = /^#([0-9a-f]{6})$/i.test(storedAccent) ? storedAccent : DEFAULT_ACCENT;
|
||||
accentPicker.value = validAccent;
|
||||
|
||||
const applyAccent = (value) => {
|
||||
const hex = /^#([0-9a-f]{6})$/i.test(value) ? value : DEFAULT_ACCENT;
|
||||
root.style.setProperty('--accent-color', hex);
|
||||
root.style.setProperty('--accent-on-color', accentContrast(hex));
|
||||
persist(ACCENT_KEY, hex);
|
||||
};
|
||||
|
||||
applyAccent(validAccent);
|
||||
accentPicker.addEventListener('input', (event) => applyAccent(event.target.value));
|
||||
}
|
||||
toggle.addEventListener('change', ()=> setTheme(toggle.checked ? 'dark' : 'light'));
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-3">Site Settings</h1>
|
||||
{% for msg in app.flashes('success') %}<div class="alert alert-success">{{ msg }}</div>{% endfor %}
|
||||
{% for msg in app.flashes('info') %}<div class="alert alert-info">{{ msg }}</div>{% endfor %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -15,6 +16,18 @@
|
||||
{{ form_label(form.SPOTIFY_CLIENT_SECRET) }}
|
||||
{{ form_widget(form.SPOTIFY_CLIENT_SECRET, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
{% set registrationToggleAttrs = {class: 'form-check-input'} %}
|
||||
{% if registrationImmutable %}
|
||||
{% set registrationToggleAttrs = registrationToggleAttrs|merge({disabled: true}) %}
|
||||
{% endif %}
|
||||
{{ form_widget(form.REGISTRATION_ENABLED, {attr: registrationToggleAttrs}) }}
|
||||
{{ form_label(form.REGISTRATION_ENABLED, null, {label_attr: {class: 'form-check-label'}}) }}
|
||||
<div class="form-text">When disabled, public sign-up is blocked but admins can still create users from `/admin/users`.</div>
|
||||
{% if registrationImmutable %}
|
||||
<div class="form-text text-warning">Locked by <code>APP_ALLOW_REGISTRATION</code> ({{ registrationOverrideValue ? 'enabled' : 'disabled' }}).</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-success" type="submit">Save settings</button>
|
||||
</div>
|
||||
|
||||
142
templates/admin/users.html.twig
Normal file
142
templates/admin/users.html.twig
Normal file
@@ -0,0 +1,142 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}User Management{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-4">User management</h1>
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="h6 mb-0">Accounts</h2>
|
||||
<span class="text-secondary small">{{ rows|length }} total</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Email</th>
|
||||
<th scope="col">Roles</th>
|
||||
<th scope="col" class="text-center">Albums</th>
|
||||
<th scope="col" class="text-center">Reviews</th>
|
||||
<th scope="col" class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
{% set user = row.user %}
|
||||
{% set isSelf = app.user and app.user.id == user.id %}
|
||||
{% set isAdminUser = 'ROLE_ADMIN' in user.roles %}
|
||||
{% set canDelete = (not isSelf) and (not isAdminUser) %}
|
||||
{% set isModerator = 'ROLE_MODERATOR' in user.roles %}
|
||||
{% set canPromote = is_granted('ROLE_ADMIN') and not isAdminUser %}
|
||||
{% set promoteReason = '' %}
|
||||
{% if not canPromote %}
|
||||
{% if not is_granted('ROLE_ADMIN') %}
|
||||
{% set promoteReason = 'Only administrators can update roles.' %}
|
||||
{% else %}
|
||||
{% set promoteReason = isModerator ? 'Demote not available.' : 'Promotion not available.' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set deleteReason = '' %}
|
||||
{% if not canDelete %}
|
||||
{% if isSelf %}
|
||||
{% set deleteReason = 'You cannot delete your own account.' %}
|
||||
{% elseif isAdminUser %}
|
||||
{% set deleteReason = 'Administrators cannot be deleted.' %}
|
||||
{% else %}
|
||||
{% set deleteReason = 'Delete not available.' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ user.displayName ?? '—' }}</div>
|
||||
</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
{% for role in user.roles %}
|
||||
{% if role == 'ROLE_ADMIN' %}
|
||||
<span class="badge text-bg-danger">Admin</span>
|
||||
{% elseif role == 'ROLE_MODERATOR' %}
|
||||
<span class="badge text-bg-primary">Moderator</span>
|
||||
{% elseif role == 'ROLE_USER' %}
|
||||
<span class="badge text-bg-secondary">User</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="text-center">{{ row.albumCount }}</td>
|
||||
<td class="text-center">{{ row.reviewCount }}</td>
|
||||
<td class="text-end">
|
||||
<div class="d-flex gap-2 justify-content-end">
|
||||
<form method="post" action="{{ path('admin_users_promote', {id: user.id}) }}" onsubmit="return confirm('{% if isModerator %}Remove moderator access from {{ user.email }}?{% else %}Promote {{ user.email }} to moderator?{% endif %}');">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('promote-user-' ~ user.id) }}">
|
||||
<span class="d-inline-block" {% if not canPromote %}data-bs-toggle="tooltip" data-bs-placement="top" title="{{ promoteReason }}" tabindex="0"{% endif %}>
|
||||
<button class="btn btn-sm btn-outline-primary" type="submit" {% if not canPromote %}disabled aria-disabled="true"{% endif %}>
|
||||
{% if isModerator %}Demote{% else %}Promote{% endif %}
|
||||
</button>
|
||||
</span>
|
||||
</form>
|
||||
<form method="post" action="{{ path('admin_users_delete', {id: user.id}) }}" onsubmit="return confirm('Delete {{ user.email }}? This cannot be undone.');">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete-user-' ~ user.id) }}">
|
||||
<span class="d-inline-block" {% if not canDelete %}data-bs-toggle="tooltip" data-bs-placement="top" title="{{ deleteReason }}" tabindex="0"{% endif %}>
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit" {% if not canDelete %}disabled aria-disabled="true"{% endif %}>Delete</button>
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-secondary py-4">No users found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Create user</h2>
|
||||
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.email, null, {label_attr: {class: 'form-label'}}) }}
|
||||
{{ form_widget(form.email, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.email) }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.displayName, null, {label_attr: {class: 'form-label'}}) }}
|
||||
{{ form_widget(form.displayName, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.displayName) }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.plainPassword.first, null, {label_attr: {class: 'form-label'}}) }}
|
||||
{{ form_widget(form.plainPassword.first, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.plainPassword.first) }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.plainPassword.second, null, {label_attr: {class: 'form-label'}}) }}
|
||||
{{ form_widget(form.plainPassword.second, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.plainPassword.second) }}
|
||||
</div>
|
||||
{{ form_errors(form.plainPassword) }}
|
||||
<button class="btn btn-success w-100" type="submit">Create account</button>
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const tooltips = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltips.forEach(function (el) {
|
||||
if (!el.getAttribute('data-bs-original-title')) {
|
||||
bootstrap.Tooltip.getOrCreateInstance(el);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,46 +1,100 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Album Search{% endblock %}
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-3">Search Albums</h1>
|
||||
<form class="row g-2 mb-2" action="{{ path('album_search') }}" method="get">
|
||||
{% set query_value = query|default('') %}
|
||||
{% set album_value = album|default('') %}
|
||||
{% set artist_value = artist|default('') %}
|
||||
{% set year_from_value = year_from|default('') %}
|
||||
{% set year_to_value = year_to|default('') %}
|
||||
{% set source_value = source|default('all') %}
|
||||
{% set has_search = (query_value is not empty) or (album_value is not empty) or (artist_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %}
|
||||
{% set advanced_open = (album_value is not empty) or (artist_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %}
|
||||
{% set landing_view = not has_search %}
|
||||
|
||||
{% if landing_view %}
|
||||
<style>
|
||||
.landing-search-form {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.landing-search-input {
|
||||
border-radius: 999px;
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
{% endif %}
|
||||
|
||||
<h1 class="{{ landing_view ? 'display-6 text-center mb-4' : 'h4 mb-3' }}">Search Albums</h1>
|
||||
<form class="{{ landing_view ? 'landing-search-form mb-4' : 'row g-2 mb-2 align-items-center' }}" action="{{ path('album_search') }}" method="get">
|
||||
{% if landing_view %}
|
||||
<div>
|
||||
<input class="form-control form-control-lg landing-search-input" type="search" name="q" value="{{ query_value }}" placeholder="Search albums, artists..." autocomplete="off" />
|
||||
</div>
|
||||
<div class="d-flex justify-content-center gap-3 mt-3">
|
||||
<button class="btn btn-success px-4" type="submit">Search</button>
|
||||
<button class="btn btn-outline-secondary px-4" type="button" data-bs-toggle="collapse" data-bs-target="#advancedSearch" aria-expanded="{{ advanced_open ? 'true' : 'false' }}" aria-controls="advancedSearch">Advanced</button>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="collapse {{ advanced_open ? 'show' : '' }}" id="advancedSearch">
|
||||
<div class="row g-2">
|
||||
<div class="col-sm-3 order-sm-4">
|
||||
<select class="form-select" name="source">
|
||||
<option value="all" {{ source_value == 'all' ? 'selected' : '' }}>All sources</option>
|
||||
<option value="spotify" {{ source_value == 'spotify' ? 'selected' : '' }}>Spotify</option>
|
||||
<option value="user" {{ source_value == 'user' ? 'selected' : '' }}>User-created</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" type="text" name="album" value="{{ album_value }}" placeholder="Album title" />
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" type="text" name="artist" value="{{ artist_value }}" placeholder="Artist" />
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input class="form-control" type="number" name="year_from" value="{{ year_from_value }}" placeholder="Year from" min="1900" max="2100" />
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input class="form-control" type="number" name="year_to" value="{{ year_to_value }}" placeholder="Year to" min="1900" max="2100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-sm">
|
||||
<input class="form-control" type="search" name="q" value="{{ query }}" placeholder="Search.." autocomplete="off" />
|
||||
<input class="form-control" type="search" name="q" value="{{ query_value }}" placeholder="Search.." autocomplete="off" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="col-auto d-flex gap-2">
|
||||
<button class="btn btn-success" type="submit">Search</button>
|
||||
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#advancedSearch" aria-expanded="{{ advanced_open ? 'true' : 'false' }}" aria-controls="advancedSearch">Advanced</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<a class="link-secondary" data-bs-toggle="collapse" href="#advancedSearch" role="button" aria-expanded="false" aria-controls="advancedSearch">Advanced search</a>
|
||||
</div>
|
||||
<div class="collapse col-12" id="advancedSearch">
|
||||
<div class="collapse col-12 {{ advanced_open ? 'show' : '' }}" id="advancedSearch">
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-sm-3 order-sm-4">
|
||||
<select class="form-select" name="source">
|
||||
<option value="all" {{ (source is defined and source == 'all') or source is not defined ? 'selected' : '' }}>All sources</option>
|
||||
<option value="spotify" {{ (source is defined and source == 'spotify') ? 'selected' : '' }}>Spotify</option>
|
||||
<option value="user" {{ (source is defined and source == 'user') ? 'selected' : '' }}>User-created</option>
|
||||
<option value="all" {{ source_value == 'all' ? 'selected' : '' }}>All sources</option>
|
||||
<option value="spotify" {{ source_value == 'spotify' ? 'selected' : '' }}>Spotify</option>
|
||||
<option value="user" {{ source_value == 'user' ? 'selected' : '' }}>User-created</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" type="text" name="album" value="{{ album }}" placeholder="Album title" />
|
||||
<input class="form-control" type="text" name="album" value="{{ album_value }}" placeholder="Album title" />
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" type="text" name="artist" value="{{ artist }}" placeholder="Artist" />
|
||||
<input class="form-control" type="text" name="artist" value="{{ artist_value }}" placeholder="Artist" />
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input class="form-control" type="number" name="year_from" value="{{ year_from }}" placeholder="Year from" min="1900" max="2100" />
|
||||
<input class="form-control" type="number" name="year_from" value="{{ year_from_value }}" placeholder="Year from" min="1900" max="2100" />
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input class="form-control" type="number" name="year_to" value="{{ year_to }}" placeholder="Year to" min="1900" max="2100" />
|
||||
<input class="form-control" type="number" name="year_to" value="{{ year_to_value }}" placeholder="Year to" min="1900" max="2100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if query is empty and (album is empty) and (artist is empty) and (year_from is empty) and (year_to is empty) %}
|
||||
<p class="text-secondary">Tip: Use the Advanced search to filter by album, artist, or year range.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if albums is defined and albums|length > 0 %}
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-3">
|
||||
{% for album in albums %}
|
||||
@@ -63,12 +117,9 @@
|
||||
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-success btn-sm" href="{{ path('album_show', {id: album.id}) }}">Reviews</a>
|
||||
{% if album.source is defined and album.source == 'user' %}
|
||||
<span class="badge text-bg-primary ms-2">User album</span>
|
||||
{% endif %}
|
||||
{% if app.user and (album.source is not defined or album.source != 'user') %}
|
||||
{% if savedIds is defined and (album.id in savedIds) %}
|
||||
<span class="badge text-bg-secondary ms-2">Saved</span>
|
||||
{# Saved indicator intentionally omitted to reduce noise #}
|
||||
{% else %}
|
||||
<form class="d-inline ms-2" method="post" action="{{ path('album_save', {id: album.id}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('save-album-' ~ album.id) }}">
|
||||
|
||||
@@ -43,12 +43,26 @@
|
||||
</div>
|
||||
<div class="vstack gap-3 mb-4">
|
||||
{% for r in reviews %}
|
||||
{% set avatar = r.author.profileImagePath ?? null %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-1">{{ r.title }} <span class="text-secondary">(Rating {{ r.rating }}/10)</span></h6>
|
||||
<div class="text-secondary mb-2">by {{ r.author.displayName ?? r.author.userIdentifier }} • {{ r.createdAt|date('Y-m-d H:i') }}</div>
|
||||
<p class="card-text">{{ r.content|u.truncate(300, '…', false) }}</p>
|
||||
<a class="btn btn-link p-0" href="{{ path('review_show', {id: r.id}) }}">Read more</a>
|
||||
<div class="d-flex gap-3">
|
||||
<div>
|
||||
{% if avatar %}
|
||||
<img src="{{ avatar }}" alt="Avatar for {{ r.author.displayName ?? r.author.userIdentifier }}" class="rounded-circle border" width="56" height="56" style="object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center" style="width:56px;height:56px;">
|
||||
{{ (r.author.displayName ?? r.author.userIdentifier)|slice(0,1)|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="card-title mb-1">{{ r.title }} <span class="text-secondary">(Rating {{ r.rating }}/10)</span></h6>
|
||||
<div class="text-secondary mb-2">by {{ r.author.displayName ?? r.author.userIdentifier }} • {{ r.createdAt|date('Y-m-d H:i') }}</div>
|
||||
<p class="card-text">{{ r.content|u.truncate(300, '…', false) }}</p>
|
||||
<a class="btn btn-link p-0" href="{{ path('review_show', {id: r.id}) }}">Read more</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
{% set accent_cookie = app.request.cookies.get('accentColor') %}
|
||||
{% set accent_color = (accent_cookie is defined and accent_cookie and accent_cookie matches '/^#[0-9a-fA-F]{6}$/') ? accent_cookie : '#6750a4' %}
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="{{ app.request.cookies.get('theme') ?? 'light' }}">
|
||||
<html lang="en" data-bs-theme="{{ app.request.cookies.get('theme') ?? 'light' }}" style="--accent-color: {{ accent_color }};">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}Music Ratings{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="{{ app.request.basePath }}/css/app.css">
|
||||
<script>
|
||||
(function() {
|
||||
const root = document.documentElement;
|
||||
const defaultAccent = '{{ accent_color|e('js') }}';
|
||||
const match = document.cookie.match(/(?:^|; )accentColor=([^;]+)/);
|
||||
const accent = match ? decodeURIComponent(match[1]) : defaultAccent;
|
||||
|
||||
function contrast(hex) {
|
||||
const normalized = hex.replace('#', '');
|
||||
if (normalized.length !== 6) {
|
||||
return '#ffffff';
|
||||
}
|
||||
const r = parseInt(normalized.substring(0, 2), 16);
|
||||
const g = parseInt(normalized.substring(2, 4), 16);
|
||||
const b = parseInt(normalized.substring(4, 6), 16);
|
||||
const luminance = (0.299 * r) + (0.587 * g) + (0.114 * b);
|
||||
return luminance > 180 ? '#1c1b20' : '#ffffff';
|
||||
}
|
||||
|
||||
root.style.setProperty('--accent-color', accent);
|
||||
root.style.setProperty('--accent-on-color', contrast(accent));
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
{% include '_partials/navbar.html.twig' %}
|
||||
|
||||
@@ -2,10 +2,25 @@
|
||||
{% block title %}{{ review.title }}{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="{{ path('account_dashboard') }}">← Back to dashboard</a></p>
|
||||
<h1 class="h4">{{ review.title }} <span class="text-secondary">(Rating {{ review.rating }}/10)</span></h1>
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
{% set avatar = review.author.profileImagePath %}
|
||||
{% if avatar %}
|
||||
<img src="{{ avatar }}" alt="Avatar for {{ review.author.displayName ?? review.author.userIdentifier }}" class="rounded-circle border" width="64" height="64" style="object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center" style="width:64px;height:64px;">
|
||||
{{ (review.author.displayName ?? review.author.userIdentifier)|slice(0,1)|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<h1 class="h4 mb-1">{{ review.title }} <span class="text-secondary">(Rating {{ review.rating }}/10)</span></h1>
|
||||
<div class="text-secondary">
|
||||
by {{ review.author.displayName ?? review.author.userIdentifier }} • {{ review.createdAt|date('Y-m-d H:i') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-secondary">
|
||||
{{ review.album.name }} — {{ review.album.artists|join(', ') }}
|
||||
<a class="ms-1" href="{{ path('album_show', {id: review.album.spotifyId}) }}">View album</a>
|
||||
<a class="ms-1" href="{{ path('album_show', {id: review.album.spotifyId ?? review.album.localId}) }}">View album</a>
|
||||
</p>
|
||||
<article class="mb-3">
|
||||
<p>{{ review.content|nl2br }}</p>
|
||||
|
||||
Reference in New Issue
Block a user