Files
Barış Keserandgoogle-labs-jules[bot] d32f89b45b feat(ui): add focus-visible classes to links for keyboard accessibility (#234)
Added `focus-visible:ring-2 focus-visible:ring-brand` classes to the episode links in `templates/home.html` and the footer links in `templates/base.html`. This ensures users navigating with a keyboard have clear, accessible focus states, resolving the issue noted in the Palette journal without affecting mouse users.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-10 17:12:06 +00:00

363 lines
18 KiB
HTML

<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}AniScrap{% endblock %}</title>
{% block meta %}{% endblock %}
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: '#ff0055',
darkbg: '#0a0a0f',
glass: 'rgba(255, 255, 255, 0.05)'
}
}
}
}
</script>
</head>
<body class="bg-darkbg text-white flex flex-col min-h-screen">
<nav class="p-4 border-b border-gray-800 bg-opacity-80 backdrop-blur-md sticky top-0 z-50">
<div class="container mx-auto flex justify-between items-center">
<a href="/" class="text-2xl font-bold text-brand tracking-widest">ANISCRAP</a>
<!-- Search Bar -->
<form role="search" aria-label="Site Search" action="{% url 'search' %}" method="get" class="hidden md:flex items-center bg-gray-800 rounded-lg px-3 py-1 border border-gray-700 focus-within:border-brand">
<input type="text" name="q" aria-label="Search query" placeholder="Search..." class="bg-transparent text-sm text-white focus:outline-none w-48">
<button type="submit" aria-label="Submit search" class="text-gray-400 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-brand rounded">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
</form>
<div class="flex items-center space-x-4">
{% if user.is_authenticated %}
<!-- Notification Bell -->
<div class="relative" id="notification-container">
<button id="notification-btn" aria-label="Notifications" aria-haspopup="true" aria-expanded="false" class="relative p-2 text-gray-300 hover:text-white transition rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-brand">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<!-- Badge -->
<span id="notification-badge" class="absolute top-0 right-0 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white transform translate-x-1/4 -translate-y-1/4 bg-brand rounded-full hidden">
0
</span>
</button>
<!-- Dropdown -->
<div id="notification-dropdown" class="absolute right-0 mt-2 w-80 bg-gray-900 border border-gray-700 rounded-lg shadow-xl overflow-hidden z-50 hidden transition-all transform origin-top-right">
<div class="p-3 border-b border-gray-700 flex justify-between items-center bg-gray-800">
<h3 class="text-sm font-semibold text-white">Notifications</h3>
<button id="mark-all-read" class="text-xs text-brand hover:text-white transition rounded px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand">Mark all read</button>
</div>
<div id="notification-list" class="max-h-64 overflow-y-auto">
<!-- Items will be injected here -->
<div class="p-4 text-center text-gray-500 text-sm">Loading...</div>
</div>
<div class="p-2 border-t border-gray-700 text-center bg-gray-800">
<a href="#" class="text-xs text-gray-400 hover:text-white transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand rounded px-1">View All</a>
</div>
</div>
</div>
<a href="{% url 'profile' %}" class="text-gray-300 hover:text-white transition font-semibold rounded px-2 py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand">Welcome, {{ user.username }}</a>
{% else %}
<a href="/admin/" class="text-gray-300 hover:text-white transition rounded px-2 py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand">Login</a>
{% endif %}
<button aria-label="Switch language" class="px-3 py-1 bg-gray-800 border border-gray-700 rounded hover:border-brand transition text-xs focus:outline-none focus-visible:ring-2 focus-visible:ring-brand">TR/EN</button>
</div>
</div>
</nav>
{% csrf_token %}
<main class="container mx-auto flex-grow p-6">
{% load ad_tags %}
<!-- Header Ad -->
<div class="mb-6 flex justify-center">
{% get_ad 'header' %}
</div>
{% block content %}{% endblock %}
</main>
<!-- Toast Container -->
<div id="toast-container" class="fixed bottom-4 right-4 z-50 flex flex-col items-end space-y-2 pointer-events-none">
<style>
#toast-container > div { pointer-events: auto; }
</style>
</div>
<footer class="bg-black py-8 border-t border-gray-900 mt-12">
<div class="container mx-auto text-center text-gray-500">
<p class="mb-2">&copy; 2026 <strong>AniScrap</strong>. All rights reserved.</p>
<p class="text-sm mb-4">Developer: <strong class="text-brand">Barış Keser</strong></p>
<div class="flex justify-center space-x-6 text-sm">
<a href="mailto:info@bariskeser.com" class="hover:text-white transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand rounded px-1">Contact</a>
<a href="mailto:abuse@bariskeser.com" class="hover:text-white transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand rounded px-1">Report Abuse</a>
<a href="mailto:dmca@bariskeser.com" class="hover:text-white transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand rounded px-1">DMCA</a>
</div>
</div>
</footer>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Toast Function
window.showToast = function(data) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
// Initial state: hidden (translate-x)
toast.className = 'bg-gray-800 border-l-4 border-brand text-white px-4 py-3 rounded shadow-2xl w-80 transform transition-all duration-300 translate-x-[120%] opacity-0';
const flexDiv = document.createElement('div');
flexDiv.className = 'flex justify-between items-start';
const textDiv = document.createElement('div');
const title = document.createElement('h4');
title.className = 'font-bold text-sm text-brand mb-1';
title.textContent = data.title;
const message = document.createElement('p');
message.className = 'text-xs text-gray-300';
message.textContent = data.message;
textDiv.appendChild(title);
textDiv.appendChild(message);
const closeBtn = document.createElement('button');
closeBtn.className = 'text-gray-500 hover:text-white ml-2 focus:outline-none';
closeBtn.setAttribute('aria-label', 'Close notification');
closeBtn.innerHTML = '<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="M6 18L18 6M6 6l12 12"></path></svg>';
flexDiv.appendChild(textDiv);
flexDiv.appendChild(closeBtn);
toast.appendChild(flexDiv);
// Click to navigate
toast.addEventListener('click', (e) => {
// If close button clicked, don't navigate
if (e.target.closest('button')) {
closeToast(toast);
return;
}
if (data.link) window.location.href = data.link;
});
container.appendChild(toast);
// Animate In
requestAnimationFrame(() => {
toast.classList.remove('translate-x-[120%]', 'opacity-0');
});
// Auto Dismiss
setTimeout(() => {
closeToast(toast);
}, 5000);
}
function closeToast(toast) {
toast.classList.add('translate-x-[120%]', 'opacity-0');
setTimeout(() => {
if(toast.parentElement) toast.remove();
}, 300);
}
const btn = document.getElementById('notification-btn');
const dropdown = document.getElementById('notification-dropdown');
const list = document.getElementById('notification-list');
const badge = document.getElementById('notification-badge');
const markAllBtn = document.getElementById('mark-all-read');
if (!btn) return; // Stop if not authenticated/elements missing
// Get CSRF Token
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken') || document.querySelector('[name=csrfmiddlewaretoken]').value;
// Toggle Dropdown
btn.addEventListener('click', function(e) {
e.stopPropagation();
dropdown.classList.toggle('hidden');
const isExpanded = !dropdown.classList.contains('hidden');
btn.setAttribute('aria-expanded', isExpanded);
if (isExpanded) {
fetchNotifications();
}
});
// Close on click outside
document.addEventListener('click', function(e) {
if (!dropdown.contains(e.target) && !btn.contains(e.target)) {
dropdown.classList.add('hidden');
btn.setAttribute('aria-expanded', 'false');
}
});
// Close on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !dropdown.classList.contains('hidden')) {
dropdown.classList.add('hidden');
btn.setAttribute('aria-expanded', 'false');
btn.focus(); // Return focus to button
}
});
// Poll for unread count
function pollUnread() {
fetch('/api/users/notifications/unread-count/')
.then(response => {
if(response.status === 401 || response.status === 403) return null;
return response.json();
})
.then(data => {
if (data && data.count > 0) {
badge.textContent = data.count > 99 ? '99+' : data.count;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
})
.catch(err => console.error('Poll Error:', err));
}
// Fetch Notification List
function fetchNotifications() {
list.innerHTML = '<div class="p-4 text-center text-gray-500 text-sm">Loading...</div>';
fetch('/api/users/notifications/')
.then(response => response.json())
.then(data => {
list.innerHTML = '';
if (data.results && data.results.length > 0) {
data.results.forEach(notif => {
const item = document.createElement('div');
item.className = `p-3 border-b border-gray-700 hover:bg-gray-800 transition cursor-pointer ${notif.is_read ? 'opacity-60' : 'bg-gray-800 bg-opacity-50'}`;
// Create elements safely to avoid XSS
const title = document.createElement('h4');
title.className = 'text-sm font-bold text-white mb-1';
title.textContent = notif.title;
const msg = document.createElement('p');
msg.className = 'text-xs text-gray-400';
msg.textContent = notif.message;
const dateSpan = document.createElement('span');
dateSpan.className = 'text-xs text-gray-600 block mt-1';
dateSpan.textContent = new Date(notif.created_at).toLocaleDateString();
item.appendChild(title);
item.appendChild(msg);
item.appendChild(dateSpan);
item.addEventListener('click', () => {
if(!notif.is_read) markRead(notif.id);
if(notif.link) window.location.href = notif.link;
});
list.appendChild(item);
});
} else {
list.innerHTML = '<div class="p-4 text-center text-gray-500 text-sm">No notifications</div>';
}
})
.catch(err => {
list.innerHTML = '<div class="p-4 text-center text-red-500 text-sm">Error loading data</div>';
});
}
// Mark single as read
function markRead(id) {
fetch(`/api/users/notifications/${id}/read/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
'Content-Type': 'application/json'
}
}).then(() => {
pollUnread(); // Update badge
});
}
// Mark all as read
markAllBtn.addEventListener('click', function(e) {
e.stopPropagation();
fetch('/api/users/notifications/read-all/', {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
'Content-Type': 'application/json'
}
}).then(() => {
pollUnread();
fetchNotifications();
});
});
// Real-time & Polling Logic
{% if user.is_authenticated %}
pollUnread();
// Backup Polling (Every 60s)
setInterval(pollUnread, 60000);
// WebSocket Connection
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = protocol + '//' + window.location.host + '/ws/notifications/';
function connectWs() {
const notifSocket = new WebSocket(wsUrl);
notifSocket.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'notification') {
// Update Badge
let count = parseInt(badge.textContent) || 0;
if (badge.classList.contains('hidden')) count = 0;
count++;
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('hidden');
// Show Toast
showToast(data);
// Refresh list if dropdown is open
if (!document.getElementById('notification-dropdown').classList.contains('hidden')) {
fetchNotifications();
}
}
};
notifSocket.onclose = function(e) {
console.error('Notification socket closed. Reconnecting in 5s...');
setTimeout(connectWs, 5000);
};
}
connectWs();
{% endif %}
});
</script>
</body>
</html>