I lowkey forgot to commit
This commit is contained in:
BIN
templates/.DS_Store
vendored
Normal file
BIN
templates/.DS_Store
vendored
Normal file
Binary file not shown.
124
templates/_partials/auth_modal.html.twig
Normal file
124
templates/_partials/auth_modal.html.twig
Normal file
@@ -0,0 +1,124 @@
|
||||
<div class="modal fade" id="authModal" tabindex="-1" aria-labelledby="authModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="authModalLabel">Account</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div data-auth-panel="login" class="d-none">
|
||||
<form data-auth-login action="{{ path('app_login') }}" method="post" class="vstack gap-2">
|
||||
<div>
|
||||
<label class="form-label">Email</label>
|
||||
<input class="form-control" type="email" name="_username" required autocomplete="email" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Password</label>
|
||||
<input class="form-control" type="password" name="_password" required autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="_remember_me" id="rememberMe" checked>
|
||||
<label class="form-check-label" for="rememberMe">Remember me</label>
|
||||
</div>
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}" />
|
||||
<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>
|
||||
</form>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const modalEl = document.getElementById('authModal');
|
||||
if (!modalEl) return;
|
||||
const bsModal = new bootstrap.Modal(modalEl);
|
||||
const panels = modalEl.querySelectorAll('[data-auth-panel]');
|
||||
function showPanel(kind){
|
||||
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'));
|
||||
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'); });
|
||||
});
|
||||
|
||||
const currentUrl = location.pathname + location.search + location.hash;
|
||||
modalEl.querySelector('[data-auth-target]')?.setAttribute('value', currentUrl);
|
||||
modalEl.querySelector('[data-auth-failure]')?.setAttribute('value', currentUrl);
|
||||
|
||||
// AJAX login
|
||||
const loginForm = modalEl.querySelector('form[data-auth-login]');
|
||||
const loginError = modalEl.querySelector('[data-auth-login-error]');
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (loginError) { loginError.classList.add('d-none'); }
|
||||
try {
|
||||
const resp = await fetch(loginForm.action, { method: 'POST', body: new FormData(loginForm), credentials: 'same-origin' });
|
||||
if (resp.ok || resp.status === 302) {
|
||||
bsModal.hide();
|
||||
location.reload();
|
||||
} else {
|
||||
if (loginError) { loginError.textContent = 'Login failed. Please check your credentials.'; loginError.classList.remove('d-none'); }
|
||||
}
|
||||
} catch (_) {
|
||||
if (loginError) { loginError.textContent = 'Network error. Please try again.'; loginError.classList.remove('d-none'); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// AJAX registration
|
||||
const regForm = modalEl.querySelector('form[data-auth-register]');
|
||||
const regError = modalEl.querySelector('[data-auth-register-error]');
|
||||
if (regForm) {
|
||||
regForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (regError) { regError.classList.add('d-none'); }
|
||||
try {
|
||||
const resp = await fetch(regForm.action, { method: 'POST', body: new FormData(regForm), credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
if (resp.ok) {
|
||||
showPanel('login');
|
||||
if (loginError) { loginError.textContent = 'Account created. You can now sign in.'; loginError.classList.remove('d-none'); }
|
||||
} else if (resp.status === 422) {
|
||||
const data = await resp.json();
|
||||
const messages = Object.values(data.errors || {}).flat().join(' ');
|
||||
if (regError) { regError.textContent = messages || 'Please correct the highlighted fields.'; regError.classList.remove('d-none'); }
|
||||
} else {
|
||||
if (regError) { regError.textContent = 'Registration failed. Please try again.'; regError.classList.remove('d-none'); }
|
||||
}
|
||||
} catch (_) {
|
||||
if (regError) { regError.textContent = 'Network error. Please try again.'; regError.classList.remove('d-none'); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// auto-open via ?auth=
|
||||
const params = new URLSearchParams(location.search);
|
||||
const authParam = params.get('auth');
|
||||
if (authParam === 'login' || authParam === 'register') {
|
||||
showPanel(authParam);
|
||||
}
|
||||
window.__openAuthModal = showPanel;
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
40
templates/_partials/navbar.html.twig
Normal file
40
templates/_partials/navbar.html.twig
Normal file
@@ -0,0 +1,40 @@
|
||||
<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>
|
||||
<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>
|
||||
<div class="collapse navbar-collapse" id="navMain">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item"><a class="nav-link" href="{{ path('review_index') }}">Your Reviews</a></li>
|
||||
</ul>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
{% if app.user %}
|
||||
<div class="dropdown">
|
||||
<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>
|
||||
<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') %}
|
||||
<li><a class="dropdown-item" href="{{ path('admin_settings') }}">Site settings</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
<li><a class="dropdown-item" href="{{ path('account_dashboard') }}">Dashboard</a></li>
|
||||
<li><a class="dropdown-item" href="{{ path('account_settings') }}">Settings</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ path('app_logout') }}">Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<button class="btn btn-success" type="button" data-open-auth="login">Login / Sign up</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
17
templates/account/dashboard.html.twig
Normal file
17
templates/account/dashboard.html.twig
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-3">Your profile</h1>
|
||||
{% for msg in app.flashes('success') %}<div class="alert alert-success">{{ msg }}</div>{% endfor %}
|
||||
|
||||
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
||||
<div>{{ form_label(form.email) }}{{ form_widget(form.email, {attr: {class: 'form-control'}}) }}{{ form_errors(form.email) }}</div>
|
||||
<div>{{ form_label(form.displayName) }}{{ form_widget(form.displayName, {attr: {class: 'form-control'}}) }}{{ form_errors(form.displayName) }}</div>
|
||||
<div>{{ form_label(form.currentPassword) }}{{ form_widget(form.currentPassword, {attr: {class: 'form-control'}}) }}{{ form_errors(form.currentPassword) }}</div>
|
||||
<div>{{ form_label(form.newPassword.first) }}{{ form_widget(form.newPassword.first, {attr: {class: 'form-control'}}) }}{{ form_errors(form.newPassword.first) }}</div>
|
||||
<div>{{ form_label(form.newPassword.second) }}{{ form_widget(form.newPassword.second, {attr: {class: 'form-control'}}) }}{{ form_errors(form.newPassword.second) }}</div>
|
||||
<button class="btn btn-success" type="submit">Save changes</button>
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
35
templates/account/settings.html.twig
Normal file
35
templates/account/settings.html.twig
Normal file
@@ -0,0 +1,35 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Settings{% endblock %}
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-3">Settings</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Appearance</h2>
|
||||
<div class="form-check form-switch mt-2">
|
||||
<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>
|
||||
</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 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();
|
||||
}
|
||||
toggle.addEventListener('change', ()=> setTheme(toggle.checked ? 'dark' : 'light'));
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
26
templates/admin/settings.html.twig
Normal file
26
templates/admin/settings.html.twig
Normal file
@@ -0,0 +1,26 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Site Settings{% endblock %}
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-3">Site Settings</h1>
|
||||
{% for msg in app.flashes('success') %}<div class="alert alert-success">{{ msg }}</div>{% endfor %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
||||
<div>
|
||||
{{ form_label(form.SPOTIFY_CLIENT_ID) }}
|
||||
{{ form_widget(form.SPOTIFY_CLIENT_ID, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ form_label(form.SPOTIFY_CLIENT_SECRET) }}
|
||||
{{ form_widget(form.SPOTIFY_CLIENT_SECRET, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-success" type="submit">Save settings</button>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
68
templates/album/search.html.twig
Normal file
68
templates/album/search.html.twig
Normal file
@@ -0,0 +1,68 @@
|
||||
{% 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">
|
||||
<div class="col-sm">
|
||||
<input class="form-control" type="search" name="q" value="{{ query }}" placeholder="Free text (optional)" autocomplete="off" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success" type="submit">Search</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="row g-2 mt-1">
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" type="text" name="album" value="{{ album }}" placeholder="Album title" />
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" type="text" name="artist" value="{{ artist }}" 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" />
|
||||
</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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 %}
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
{% set image = (album.images[1] ?? album.images[0] ?? null) %}
|
||||
{% if image %}
|
||||
<a href="{{ path('album_show', {id: album.id}) }}">
|
||||
<img class="card-img-top" src="{{ image.url }}" alt="{{ album.name }} cover" />
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h5 class="card-title"><a href="{{ path('album_show', {id: album.id}) }}" class="text-decoration-none">{{ album.name }}</a></h5>
|
||||
<p class="card-text text-secondary">{{ album.artists|map(a => a.name)|join(', ') }}</p>
|
||||
<p class="card-text text-secondary">Released {{ album.release_date }} • {{ album.total_tracks }} tracks</p>
|
||||
{% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %}
|
||||
<p class="card-text"><small class="text-secondary">User score: {{ s.avg }}/10 ({{ s.count }})</small></p>
|
||||
<div class="mt-auto">
|
||||
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a>
|
||||
<a class="btn btn-success btn-sm" href="{{ path('album_show', {id: album.id}) }}">Reviews</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elseif query or album or artist or year_from or year_to %}
|
||||
<p>No albums found.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
68
templates/album/show.html.twig
Normal file
68
templates/album/show.html.twig
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}{{ album.name }} — Reviews{% endblock %}
|
||||
{% block body %}
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
{% set image = (album.images[1] ?? album.images[0] ?? null) %}
|
||||
{% if image %}
|
||||
<img class="card-img-top" src="{{ image.url }}" alt="{{ album.name }} cover" />
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-1">{{ album.name }}</h5>
|
||||
<div class="text-secondary mb-2">{{ album.artists|map(a => a.name)|join(', ') }}</div>
|
||||
<p class="text-secondary mb-2">Released {{ album.release_date }} • {{ album.total_tracks }} tracks</p>
|
||||
<p class="mb-2"><strong>User score:</strong> {{ avg }}/10 ({{ count }})</p>
|
||||
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h2 class="h5 mb-0">Reviews</h2>
|
||||
</div>
|
||||
<div class="vstack gap-3 mb-4">
|
||||
{% for r in reviews %}
|
||||
<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>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-secondary">No reviews yet for this album.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if app.user %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h3 class="h6">Leave a review</h3>
|
||||
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
||||
<div>{{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}</div>
|
||||
<div>{{ form_label(form.content) }}{{ form_widget(form.content, {attr: {class: 'form-control', rows: 6}}) }}{{ form_errors(form.content) }}</div>
|
||||
<div>
|
||||
{{ form_label(form.rating) }}
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
{{ form_widget(form.rating, {attr: {class: 'form-range', min:1, max:10, step:1, list:'rating-ticks', oninput:'document.getElementById("rating-value").textContent=this.value;'}}) }}
|
||||
<span id="rating-value" class="badge text-bg-success">{{ form.rating.vars.value ?? 5 }}</span>
|
||||
</div>
|
||||
<datalist id="rating-ticks">
|
||||
{% for i in 1..10 %}<option value="{{ i }}">{{ i }}</option>{% endfor %}
|
||||
</datalist>
|
||||
{{ form_errors(form.rating) }}
|
||||
</div>
|
||||
<button class="btn btn-success" type="submit">Post review</button>
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">Sign in to leave a review. <button class="btn btn-sm btn-success ms-2" type="button" data-open-auth="login">Login / Sign up</button></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
20
templates/base.html.twig
Normal file
20
templates/base.html.twig
Normal file
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="{{ app.request.cookies.get('theme') ?? 'light' }}">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
{% include '_partials/navbar.html.twig' %}
|
||||
<main class="container py-4">
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
{% include '_partials/auth_modal.html.twig' %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
17
templates/review/edit.html.twig
Normal file
17
templates/review/edit.html.twig
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Edit Review{% endblock %}
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-1">Edit review</h1>
|
||||
<p class="text-secondary">{{ review.albumName }} — {{ review.albumArtist }} ({{ review.spotifyAlbumId }})</p>
|
||||
|
||||
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
||||
<div>{{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}</div>
|
||||
<div>{{ form_label(form.content) }}{{ form_widget(form.content, {attr: {class: 'form-control', rows: 8}}) }}{{ form_errors(form.content) }}</div>
|
||||
<div>{{ form_label(form.rating) }}{{ form_widget(form.rating, {attr: {class: 'form-control'}}) }}{{ form_errors(form.rating) }}</div>
|
||||
<button class="btn btn-success" type="submit">Update review</button>
|
||||
{{ form_end(form) }}
|
||||
|
||||
<p class="mt-3"><a href="{{ path('review_show', {id: review.id}) }}">Back to review</a></p>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
29
templates/review/index.html.twig
Normal file
29
templates/review/index.html.twig
Normal file
@@ -0,0 +1,29 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Album Reviews{% endblock %}
|
||||
{% block body %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h4 mb-0">Album reviews</h1>
|
||||
{% if app.user %}
|
||||
<a class="btn btn-success" href="{{ path('review_new') }}">New review</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for r in reviews %}
|
||||
<div class="col-12">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-1">{{ r.title }} <span class="text-secondary">(Rating {{ r.rating }}/10)</span></h5>
|
||||
<div class="text-secondary mb-2">{{ r.albumName }} — {{ r.albumArtist }}</div>
|
||||
<p class="card-text">{{ r.content|u.truncate(220, '…', false) }}</p>
|
||||
<a class="btn btn-link p-0" href="{{ path('review_show', {id: r.id}) }}">Read more</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No reviews yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
30
templates/review/new.html.twig
Normal file
30
templates/review/new.html.twig
Normal file
@@ -0,0 +1,30 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}New Review{% endblock %}
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-3">Write a review</h1>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Spotify Album ID</label>
|
||||
<input class="form-control" type="text" name="spotifyAlbumId" value="{{ review.spotifyAlbumId }}" placeholder="e.g. 4m2880jivSbbyEGAKfITCa" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Artist</label>
|
||||
<input class="form-control" type="text" name="albumArtist" value="{{ review.albumArtist }}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Album Title</label>
|
||||
<input class="form-control" type="text" name="albumName" value="{{ review.albumName }}" />
|
||||
</div>
|
||||
|
||||
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
||||
<div>{{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}</div>
|
||||
<div>{{ form_label(form.content) }}{{ form_widget(form.content, {attr: {class: 'form-control', rows: 8}}) }}{{ form_errors(form.content) }}</div>
|
||||
<div>{{ form_label(form.rating) }}{{ form_widget(form.rating, {attr: {class: 'form-control'}}) }}{{ form_errors(form.rating) }}</div>
|
||||
<button class="btn btn-success" type="submit">Save review</button>
|
||||
{{ form_end(form) }}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
22
templates/review/show.html.twig
Normal file
22
templates/review/show.html.twig
Normal file
@@ -0,0 +1,22 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}{{ review.title }}{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="{{ path('review_index') }}">← Back</a></p>
|
||||
<h1 class="h4">{{ review.title }} <span class="text-secondary">(Rating {{ review.rating }}/10)</span></h1>
|
||||
<p class="text-secondary">{{ review.albumName }} — {{ review.albumArtist }} ({{ review.spotifyAlbumId }})</p>
|
||||
<article class="mb-3">
|
||||
<p>{{ review.content|nl2br }}</p>
|
||||
</article>
|
||||
|
||||
{% if is_granted('REVIEW_EDIT', review) %}
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-outline-secondary" href="{{ path('review_edit', {id: review.id}) }}">Edit</a>
|
||||
<form action="{{ path('review_delete', {id: review.id}) }}" method="post" onsubmit="return confirm('Delete this review?')">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_review_' ~ review.id) }}" />
|
||||
<button class="btn btn-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user